diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..59bd581 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +jobs: + unit: + name: Unit tests (node ${{ matrix.node }}) + runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - run: make check + - run: make test + + e2e: + name: Browser e2e (Chrome for Testing) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Install Chrome for Testing into the holodeck + id: chrome + run: | + mkdir -p "$HOME/.holodeck/browsers" + out=$(npx --yes @puppeteer/browsers install chrome@stable --path "$HOME/.holodeck/browsers") + echo "$out" + echo "path=${out#* }" >> "$GITHUB_OUTPUT" + - name: Run e2e + env: + BROWSER: ${{ steps.chrome.outputs.path }} + run: make e2e + + package: + name: Package zip + runs-on: ubuntu-latest + needs: [unit, e2e] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: make package + - uses: actions/upload-artifact@v4 + with: + name: organize-tabs-zip + path: organize_tabs-*.zip + if-no-files-found: error diff --git a/.gitignore b/.gitignore index c1f854e..dbfbac7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .DS_Store *.zip +node_modules/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0c9666b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# Organize Tabs: notes for AI agents and contributors + +## What this is + +A Chrome extension (Manifest V3) that groups, sorts, deduplicates and tidies tabs. It is the maintainer's daily driver, used across many machines and browser profiles. Keep it that way: vanilla JavaScript, no build step, no runtime dependencies. + +## Layout + +``` +manifest.json +src/background.js service worker: message router, context menus, shortcuts, auto-dedupe, badge +src/lib/url.js pure URL logic: normalization, entity rules, duplicate detection, sort order (tested) +src/lib/settings.js chrome.storage.sync persistence, export/import, remote rules +src/lib/actions.js the action registry and everything that touches the tabs/windows/bookmarks APIs +src/popup.* toolbar popup (sends messages, runs nothing itself) +src/options.* settings page +src/ui.css shared design tokens, light and dark +test/url.test.js node --test suite for src/lib/url.js +scripts/e2e.mjs headless browser test over the DevTools protocol +scripts/dev.mjs launches the holodeck dev profile with sample tabs +scripts/screenshots.mjs renders README images and 1280x800 store screenshots +STORE_LISTING.md Chrome Web Store copy, permission justifications, privacy answers +``` + +## Rules of the road + +- **All tab operations run in the service worker.** The popup only sends `chrome.runtime.sendMessage` and renders results. Chrome closes the popup the moment a window is created or focused, which kills any logic running there. +- **Never unpin or reorder a pinned tab.** Sort, group, split and the cleanup actions skip them entirely. The two actions that move pinned tabs across windows on purpose (Consolidate All, Pull Media) re-pin them at the front of the destination immediately, because Chrome unpins on move. +- **Never break a tab group.** Sort keeps groups contiguous. Cross-window moves go through `moveTabsPreservingGroups` in `src/lib/actions.js`, which moves whole groups, merges into a same-named group in the destination, and rebuilds partial groups. +- **Windows holding a meeting or playing media are primary.** Consolidate and Split go through `withoutProtectedWindows`; new window-rearranging actions should too. `isMediaTab` in `src/lib/url.js` defines media (audible, or a URL matching the user's media patterns). +- **Anything that closes tabs with content goes through `closeTabs`** so it lands in the undo stack, and exposes a `preview` so the popup can show the list first. Blank new-tab pages are the one exception: they carry nothing to restore. Tabs the extension itself opens (Undo, Restore) are registered with `markOwnTab` so auto-dedupe leaves them alone. +- **Pure logic lives in `src/lib/url.js`** and gets a unit test. New duplicate rules go in `DEFAULT_RULES` with a case in `test/url.test.js` showing the URL variants that must collapse and one that must not. +- Keep the existing code style (`.prettierrc`: 4 spaces, single quotes, semicolons). + +## Testing + +```sh +make test # unit tests, no browser needed +make e2e # every action in a real headless browser +make dev # interactive: the holodeck +``` + +### The holodeck + +Browser testing never touches a real profile. Automated and interactive runs use the **holodeck**, a throwaway sandbox at `~/.holodeck/` (override with `HOLODECK=`): + +- `make dev` opens Brave (or `BROWSER=/path/to/chromium make dev`) on `~/.holodeck/brave`, named "🧪 holodeck" in the profile chip, with this folder loaded as an unpacked extension and two windows of sample tabs that hit every rule. The profile persists between runs. `make dev-clean` deletes it. +- `make e2e` creates a fresh profile under `~/.holodeck/tmp/` and removes it when the run ends. +- Google Chrome's branded build ignores `--load-extension`, so the scripts default to Brave. Loading unpacked through `chrome://extensions` works in any browser. + +Agents may launch, inspect and destroy anything under `~/.holodeck/` without asking. + +CI (`.github/workflows/ci.yml`) runs `make check`, `make test` on Node 22 and 24, and `make e2e` on Ubuntu with Chrome for Testing from `@puppeteer/browsers`. When `CI` is set the e2e script adds `--no-sandbox` and friends for containers. + +## Releasing + +1. Bump `version` in `manifest.json` and `package.json`. The manifest `description` is the store summary and must stay at or under 132 characters. +2. `make screenshots` if the UI changed, and update `STORE_LISTING.md` if actions or permissions changed. +3. Merge, tag `vX.Y.Z`, create a GitHub Release with the CI zip attached. +4. Upload the zip to the Chrome Web Store dashboard and paste from `STORE_LISTING.md`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Makefile b/Makefile index f4dc5f4..4b8a811 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,38 @@ VERSION := $(shell jq < manifest.json .version -r) +.PHONY: help test check e2e dev dev-clean screenshots clean package ## help - Display help about make targets for this Makefile help: @cat Makefile | grep '^## ' --color=never | cut -c4- | sed -e "`printf 's/ - /\t- /;'`" | column -s "`printf '\t'`" -t +## test - run the unit tests +test: + node --test + +## check - syntax-check every script +check: + @for f in src/*.js src/lib/*.js; do node --check $$f && echo "ok $$f"; done + ## clean - remove all build artifacts clean: rm -f *.zip -## package - creates a new package -package: clean - 7z a organize_tabs-${VERSION}.zip `cat FILES` +## package - build organize_tabs-.zip for the Chrome Web Store (or Load unpacked after unzipping) +package: clean test + zip -r -X organize_tabs-${VERSION}.zip `cat FILES` -x '*.DS_Store' + +## e2e - run the browser end-to-end test (needs Brave or Chromium) +e2e: + node scripts/e2e.mjs + +## dev - launch the holodeck (throwaway browser profile at ~/.holodeck) with the extension and sample tabs +dev: + node scripts/dev.mjs + +## dev-clean - end program: delete the holodeck browser profiles +dev-clean: + rm -rf "$${HOLODECK:-$$HOME/.holodeck}"/brave "$${HOLODECK:-$$HOME/.holodeck}"/chromium "$${HOLODECK:-$$HOME/.holodeck}"/tmp + +## screenshots - render README and Chrome Web Store screenshots from the holodeck +screenshots: + node scripts/screenshots.mjs diff --git a/README.md b/README.md index 2c8d169..ab69bda 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,158 @@ -# Organize Tabs Google Chrome Extension +# Organize Tabs -This plugin enables the following operations: +A Chrome extension for people who keep a lot of tabs open. It groups, sorts, deduplicates and tidies tabs across every window, and it understands that a pull request, a ticket, a profile or a document is the same page even when the URL differs. -- **Collate Tabs** - Groups all tabs into separate windows, grouped by hostname, and sorted by URL. Ignores windows with pinned tabs. -- **Consolidate Tabs** - Combines all tabs across all windows into one window, sorted by URL. Ignores windows with pinned tabs. -- **Deduplicate Tabs** - Enumerates through all tabs, and keeps only the first copy of a tab with a unique URL, and closes the remaining tabs with duplicate URLs. Recommend running this *after* running **Collate Tabs**. -- **Sort Tabs in Window** - Sorts tabs in current window by URL. -- **Close All Tabs from this Domain** - Close all tabs opened to the same domain/host as the current tab. -- **Close Blank Tabs** - Closes all blank tabs. -- **Bring All Windows To Front** - Focuses all windows in session and tiles them. Useful for rediscovering misplaced windows. +Vanilla JavaScript, no build step, no dependencies. Works in Chrome, Brave, Edge and other Chromium browsers (Chrome 121 or newer). -Available on the Google Chrome Webstore: +Chrome Web Store: -## Screenshots +

+ Popup + Preview before closing duplicates + Popup with inline help +

-![image](https://github.com/hacktoolkit/organize-tabs-chrome-extension/raw/master/promo/screenshot1.png) +## Actions -## Development and Testing +Every action is available from the toolbar popup, the right-click context menu, and (optionally) a keyboard shortcut. Actions that close tabs show a preview first, and the last close can always be undone. -1. Clone this repository locally. -1. In Google Chrome, go to `Manage Extensions` (). -1. Enable `Developer mode`. -1. Press `Load unpacked` and navigate to the folder with `manifest.json`, and `Select`. -1. Voilà! +### Organize + +| Action | What it does | +| --- | --- | +| **Group Tabs by Domain** | Puts tabs from the same site into a tab group, in every window. Pinned tabs and existing groups are left alone. | +| **Sort Tabs in Window** | Sorts the current window by site and URL. Pinned tabs stay put, tab groups stay together and are sorted by name. | +| **Ungroup Tabs in Window** | Removes all tab groups in the current window. | +| **Consolidate All Tabs** | Moves every tab, pinned ones included, into the current window and sorts it. Tab groups come along, and a group joins an existing group with the same name. | +| **Consolidate Unpinned Tabs** | Same, but pinned tabs stay in their windows. | +| **Split Windows by Domain** | One window per site, plus one window for the sites with a single tab. Tab groups are kept. The old "Collate" action. | + +### Clean up + +| Action | What it does | +| --- | --- | +| **Deduplicate Tabs** | Closes tabs that point at the same thing, using the duplicate rules below. Keeps the pinned, active, or most recently viewed copy. | +| **Close Tabs Like This One** | Closes tabs from the same domain, the same section of the site, or the same kind of page (all pull requests, all LinkedIn profiles). | +| **Close Stale Tabs** | Closes tabs you have not looked at for N days. Pinned, active and playing tabs are skipped. | +| **Free Memory** | Unloads stale tabs from memory without closing them. | +| **Close Blank Tabs** | Closes empty new-tab pages everywhere. | +| **Park Window to Bookmarks** | Saves the current window's tabs into a dated bookmark folder and closes them. | +| **Restore Last Parked** | Reopens the most recently parked folder as a tab group. | + +### Windows + +| Action | What it does | +| --- | --- | +| **Pull Meetings & Media to a Window** | Moves every tab that is playing audio or showing a meeting or video player into its own window and focuses the meeting. | +| **Bring All Windows to Front** | Focuses and cascades every window so misplaced ones reappear. | +| **Undo Last Close** | Reopens whatever the last action closed. | + +## Duplicate rules + +Two URLs are duplicates when they produce the same **key**. Without a rule, the key is the URL with the scheme, `www.`, fragment, trailing slash, tracking parameters and query-parameter order normalized away. With a rule, many URLs collapse to one key: + +```json +{ + "name": "GitHub pull request", + "match": "^https?://github\\.com/([^/]+)/([^/]+)/pull/(\\d+)", + "key": "github:$1/$2/pull/$3" +} +``` + +That rule makes `/pull/123`, `/pull/123/files`, `/pull/123/commits/abc` and `/pull/123#discussion_r1` the same tab. Built-in rules cover GitHub, Bitbucket, GitLab, Jira, Confluence, LinkedIn, YouTube, Google Docs and Drive, Notion, HubSpot, Slack, Figma, Amazon, Stack Overflow, Reddit and X. Add your own in Settings, where a tester shows which rule a URL hits. + +The same rules power **Close Tabs Like This One**, so from any pull request you can close every open pull request with one click. + +**Auto-deduplicate** (off by default) watches new tabs. When you open a link that is already open, the new tab is closed and the existing one focused. + +## Settings, sync and backup + +Open Settings from the popup or from `chrome://extensions`. + +- Preferences and rules are stored in `chrome.storage.sync`, so they follow a signed-in Chrome or Brave profile across machines. +- **Export** downloads a JSON file with everything. **Import** reads it back, on any profile. +- **Remote rules file**: point at a JSON file you host (a raw file in a dotfiles repo works well). Its rules are merged in after your local ones and refreshed on a schedule. + +A remote rules file is either a full export or just `{"rules": [...]}`. + +## Keyboard shortcuts + +Defaults: `Alt+Shift+O` opens the popup, `Alt+Shift+D` deduplicates, `Alt+Shift+S` sorts the window, `Alt+Shift+G` groups by domain. Every other action can be bound at `chrome://extensions/shortcuts`. + +## Meetings and media + +Being on a call while tidying up is the common case, so a window holding a meeting or a playing tab is treated as primary: **Consolidate** and **Split** leave it alone (a setting, on by default). A tab counts as media when it is audible or on a meeting or player page: Google Meet, Zoom, Teams, Whereby, Around, Webex, Gather, Discord, Slack huddles, YouTube, YouTube Music, Twitch, Vimeo, Spotify, Netflix and Loom by default, editable in Settings. **Pull Meetings & Media to a Window** gathers all of them into one window, which is a good first click when a call starts. + +## Tab groups and pinned tabs + +No action ever moves a pinned tab out of its place, and no action breaks a tab group: + +- **Sort** keeps groups contiguous and sorts them by name, with members sorted inside. +- **Consolidate** and **Split** move whole groups between windows. When the destination already has a group with the same name, the incoming tabs join it. Untitled groups keep their colour and members. +- **Group by Domain** adds tabs to an existing group with the matching name instead of creating a second one. + +## Development + +```sh +git clone git@github.com:hacktoolkit/organize-tabs-chrome-extension.git +cd organize-tabs-chrome-extension +make test # unit tests for the URL and rule logic (node --test) +make e2e # headless browser test of every action (needs Brave or Chromium) +make dev # open a throwaway browser profile with the extension and sample tabs +``` + +CI runs both suites on every push and pull request (`.github/workflows/ci.yml`): unit tests on Node 22 and 24, and the e2e suite on Ubuntu with Chrome for Testing installed via `@puppeteer/browsers`. To use that same browser locally instead of Brave: + +```sh +npx --yes @puppeteer/browsers install chrome@stable --path ~/.holodeck/browsers +BROWSER="" make e2e +``` + +`make dev` launches the **holodeck**: Brave (or `BROWSER=/path/to/chromium make dev`) on a throwaway profile at `~/.holodeck/brave`, named "🧪 holodeck" in the profile chip, with the extension loaded from this folder and two windows of sample tabs that exercise every rule. The profile persists between runs so settings, parked bookmarks and shortcuts survive; `make dev-clean` deletes it. `make e2e` uses a fresh profile under `~/.holodeck/tmp` that is removed when the run ends. Nothing under `~/.holodeck` is real, so humans and AI agents can trash it freely. After editing source, reload the extension at `chrome://extensions`. + +To use it in your real profile: `chrome://extensions`, enable Developer mode, **Load unpacked**, pick the folder with `manifest.json`. Google Chrome's branded build no longer accepts `--load-extension` on the command line, which is why the scripts default to Brave; loading unpacked through the UI works in every browser. + +Layout: + +``` +manifest.json +src/background.js service worker: message router, context menus, shortcuts, auto-dedupe, badge +src/lib/url.js pure URL logic: normalization, rules, duplicate detection, sorting (tested) +src/lib/settings.js storage.sync persistence, export/import, remote rules +src/lib/actions.js the action registry and everything that touches the tabs API +src/popup.* toolbar popup +src/options.* settings page +test/ node --test suites +``` + +All tab operations run in the service worker. The popup only sends messages, because Chrome closes the popup as soon as a new window is created or focused. + +## Privacy + +Organize Tabs does not collect, store remotely, or transmit any data. Tab URLs and titles are read and processed only inside your browser to find duplicates, sort and group. Preferences and rules are saved in your browser profile's own storage. The only network request the extension can make is to fetch a rules file at a URL you configure yourself, and that is off by default. ## Building and publishing -See: https://developer.chrome.com/webstore/publish +```sh +make package # organize_tabs-.zip +``` + +`STORE_LISTING.md` has the store description, permission justifications and privacy answers to paste into the dashboard, and `make screenshots` regenerates the README images and the 1280x800 store screenshots in `promo/store/` from the holodeck. + +CI also attaches the zip to every run as the `organize-tabs-zip` artifact (Actions tab, pick a run, Artifacts). To try a build on another machine without cloning: download it, unzip, then `chrome://extensions` → Developer mode → **Load unpacked** → pick the unzipped folder. +See . ## Contributing -Contributions are welcome. Please fork the repository and submit a pull request from your local branch. +Contributions are welcome, especially new duplicate rules with tests in `test/url.test.js`. Fork the repository and open a pull request. ## Support -Check out other Chrome extensions made by Hacktoolkit! +Check out other Chrome extensions made by Hacktoolkit. ## License MIT. See LICENSE.md. -Logo: Derived from `window-restore` [icon](https://fontawesome.com/icons/window-restore?style=regular) from Font Awesome (License: ). +Logo: derived from the `window-restore` [icon](https://fontawesome.com/icons/window-restore?style=regular) from Font Awesome (License: ). diff --git a/STORE_LISTING.md b/STORE_LISTING.md new file mode 100644 index 0000000..68663b1 --- /dev/null +++ b/STORE_LISTING.md @@ -0,0 +1,106 @@ +# Chrome Web Store listing kit + +Everything to paste into the developer dashboard for a release. Regenerate the images with `make screenshots`. + +Listing: +Dashboard: + +## Package + +`make package` builds `organize_tabs-.zip`. CI attaches the same zip to every run as the `organize-tabs-zip` artifact. + +## Store listing tab + +**Summary** (comes from `manifest.json` → `description`, max 132 characters): + +> Group, sort, deduplicate and tidy tabs. Catches duplicate PRs, tickets, profiles and docs even when the URLs differ. + +**Category:** Productivity → Tools (Workflow & Planning also fits) + +**Language:** English + +**Detailed description:** + +``` +Organize Tabs is for people who keep a lot of tabs open. One click groups, sorts, deduplicates and tidies tabs across every window, and it knows that a pull request, a ticket, a profile or a document is the same page even when the URL is different. + +DEDUPLICATE, PROPERLY +A pull request and its Files tab. A Jira issue and its board link. A LinkedIn profile and its Experience page. A YouTube video and its short link. A Google Doc in edit and view mode. Built-in rules collapse all of these to one tab and keep the pinned, active, or most recently viewed copy. Rules are editable, with a live tester, and you can add your own. + +TURN IT ON AND FORGET IT +Auto-deduplicate closes a freshly opened link that is already open somewhere and jumps you to the existing tab. Off by default. + +CLOSE TABS LIKE THIS ONE +From any tab, close everything on the same domain, in the same section of the site, or the same kind of page: every open pull request, every profile. Keep the one you are on. Always with a preview first, always with undo. + +ORGANIZE +• Group Tabs by Domain using native tab groups +• Sort Tabs in Window by site and URL, keeping groups together +• Consolidate every window into one, or split windows by domain +• Pinned tabs never move. Tab groups travel between windows intact and merge into a group with the same name. + +MEETINGS AND MEDIA +Pull every meeting and playing tab into its own window with one click. Windows holding a call or a player are left alone by Consolidate and Split, so a tidy-up never pulls the meeting out from under you. + +CLEAN UP +• Close stale tabs you have not looked at in days +• Free memory by discarding stale tabs without closing them +• Close blank tabs +• Park a whole window to a dated bookmark folder, and restore it later as a tab group +• Undo the last close + +SETTINGS THAT FOLLOW YOU +Preferences and rules sync with your browser profile. Export and import JSON to move between profiles, or point at a rules file you host and it refreshes on a schedule. + +Keyboard shortcuts for every action, a duplicate count on the toolbar icon, a right-click menu, light and dark themes, inline help. + +Vanilla JavaScript, no build step, no dependencies, no tracking, no network requests except an optional rules file you configure. Open source under the MIT license: https://github.com/hacktoolkit/organize-tabs-chrome-extension +``` + +**Screenshots** (1280x800, upload in this order from `promo/store/`): + +1. `1-popup.png` — the popup with live counts +2. `2-dedupe.png` — duplicate preview +3. `3-scope.png` — close tabs like this one +4. `4-help.png` — inline help +5. `5-rules.png` — settings and rules + +**Icon:** `img/icon128.png` + +**Small promo tile (440x280) and marquee (1400x560):** optional; not generated yet. + +## Privacy tab + +**Single purpose:** + +> Organize Tabs organizes the user's open browser tabs: grouping, sorting, deduplicating, closing and moving them between windows. + +**Permission justifications:** + +| Permission | Justification | +| --- | --- | +| `tabs` | Read tab URLs and titles to find duplicates, sort by URL, group by site, and show previews; move and close tabs. This is the extension's entire function. | +| `tabGroups` | Create, name and colour tab groups for "Group Tabs by Domain", and keep existing groups intact when sorting or moving tabs between windows. | +| `storage` | Save the user's preferences and duplicate rules, synced with the browser profile, plus the session undo stack. | +| `bookmarks` | "Park Window to Bookmarks" saves the current window's tabs into a bookmark folder the user names, and "Restore Last Parked" reopens them. No other bookmarks are read or modified. | +| `contextMenus` | Offer every action from the page right-click menu. | +| `alarms` | Refresh the optional remote rules file on the schedule the user sets. | +| Optional host permissions `https://*/*`, `http://*/*` | Requested at runtime, only for the origin of a remote rules file the user explicitly configures, so it can be fetched when the host does not send CORS headers. Never requested otherwise. | + +**Remote code:** No. All code ships in the package. The optional remote rules file is JSON data, parsed and validated, never executed. + +**Data usage:** The extension does not collect, transmit or sell any user data. Tab URLs and titles are processed locally in the browser only. Check "Does not collect or use user data" or the equivalent; if the form insists on a declaration, the only data handled is "Website content (URLs and page titles of open tabs)", used for the extension's core functionality, not transmitted off the device. + +**Privacy policy URL:** the README section "Privacy" at https://github.com/hacktoolkit/organize-tabs-chrome-extension#privacy + +## Distribution tab + +Visibility: Public. Regions: all. + +## Why users see a permission prompt on this update + +Version 2.0.0 adds `tabGroups`, `storage`, `bookmarks` and `alarms`. Chrome disables the extension until the user accepts the new permissions. Mention it in the release notes. + +## Release notes (paste into the GitHub Release) + +See the pull request description for v2.0.0: https://github.com/hacktoolkit/organize-tabs-chrome-extension/pull/9 diff --git a/img/icon128.png b/img/icon128.png new file mode 100644 index 0000000..94f6be8 Binary files /dev/null and b/img/icon128.png differ diff --git a/img/icon16.png b/img/icon16.png new file mode 100644 index 0000000..c996eb8 Binary files /dev/null and b/img/icon16.png differ diff --git a/img/icon32.png b/img/icon32.png new file mode 100644 index 0000000..93e0c98 Binary files /dev/null and b/img/icon32.png differ diff --git a/img/icon48.png b/img/icon48.png new file mode 100644 index 0000000..240b204 Binary files /dev/null and b/img/icon48.png differ diff --git a/manifest.json b/manifest.json index f455ff4..f262338 100644 --- a/manifest.json +++ b/manifest.json @@ -1,37 +1,67 @@ { "manifest_version": 3, - "name": "Organize Tabs", - "version": "0.8.0", - - "description": "Organize tabs in Google Chrome.", - - "icons" : { - "16" : "img/organize_tabs_icon_128.png", - "64" : "img/organize_tabs_icon_128.png", - "128" : "img/organize_tabs_icon_128.png" + "short_name": "Organize Tabs", + "version": "2.0.0", + "description": "Group, sort, deduplicate and tidy tabs. Catches duplicate PRs, tickets, profiles and docs even when the URLs differ.", + "author": "Hacktoolkit (@hacktoolkit)", + "minimum_chrome_version": "121", + "icons": { + "16": "img/icon16.png", + "32": "img/icon32.png", + "48": "img/icon48.png", + "128": "img/icon128.png" }, - "action": { - "default_icon": "img/organize_tabs_icon_128.png", + "default_icon": { + "16": "img/icon16.png", + "32": "img/icon32.png" + }, "default_popup": "src/popup.html", "default_title": "Organize Tabs" }, - - "author": "Hacktoolkit (@hacktoolkit)", - "options_ui": { - "page": "src/options.html" + "page": "src/options.html", + "open_in_tab": true + }, + "background": { + "service_worker": "src/background.js", + "type": "module" }, - "permissions": [ + "tabs", + "tabGroups", + "storage", + "bookmarks", "contextMenus", - "tabs" + "alarms" ], - - "background": { - "service_worker": "src/js/organize_tabs.js" - }, - - "short_name": "Organize Tabs" + "optional_host_permissions": ["https://*/*", "http://*/*"], + "commands": { + "_execute_action": { + "suggested_key": { "default": "Alt+Shift+O" }, + "description": "Open Organize Tabs" + }, + "dedupe": { + "suggested_key": { "default": "Alt+Shift+D" }, + "description": "Deduplicate tabs" + }, + "sortWindow": { + "suggested_key": { "default": "Alt+Shift+S" }, + "description": "Sort tabs in window" + }, + "groupByDomain": { + "suggested_key": { "default": "Alt+Shift+G" }, + "description": "Group tabs by domain" + }, + "closeScope": { "description": "Close tabs from this domain" }, + "closeBlank": { "description": "Close blank tabs" }, + "closeStale": { "description": "Close stale tabs" }, + "discardStale": { "description": "Free memory (discard stale tabs)" }, + "consolidateAll": { "description": "Consolidate all tabs" }, + "consolidateUnpinned": { "description": "Consolidate unpinned tabs" }, + "parkWindow": { "description": "Park window to bookmarks" }, + "pullMedia": { "description": "Pull meetings and media to a window" }, + "undo": { "description": "Undo last close" } + } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..2d53b53 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "name": "organize-tabs-chrome-extension", + "version": "2.0.0", + "private": true, + "type": "module", + "scripts": { + "test": "node --test" + } +} diff --git a/promo/options.png b/promo/options.png new file mode 100644 index 0000000..7d1e88d Binary files /dev/null and b/promo/options.png differ diff --git a/promo/popup-dark.png b/promo/popup-dark.png new file mode 100644 index 0000000..7cfde45 Binary files /dev/null and b/promo/popup-dark.png differ diff --git a/promo/popup-help.png b/promo/popup-help.png new file mode 100644 index 0000000..01272ea Binary files /dev/null and b/promo/popup-help.png differ diff --git a/promo/popup.png b/promo/popup.png new file mode 100644 index 0000000..effec7c Binary files /dev/null and b/promo/popup.png differ diff --git a/promo/preview.png b/promo/preview.png new file mode 100644 index 0000000..d42cb5f Binary files /dev/null and b/promo/preview.png differ diff --git a/promo/scope.png b/promo/scope.png new file mode 100644 index 0000000..efde174 Binary files /dev/null and b/promo/scope.png differ diff --git a/promo/screenshot1.png b/promo/screenshot1.png deleted file mode 100644 index 0e8c82c..0000000 Binary files a/promo/screenshot1.png and /dev/null differ diff --git a/promo/store/1-popup.png b/promo/store/1-popup.png new file mode 100644 index 0000000..023e5f9 Binary files /dev/null and b/promo/store/1-popup.png differ diff --git a/promo/store/2-dedupe.png b/promo/store/2-dedupe.png new file mode 100644 index 0000000..f51ac87 Binary files /dev/null and b/promo/store/2-dedupe.png differ diff --git a/promo/store/3-scope.png b/promo/store/3-scope.png new file mode 100644 index 0000000..642e429 Binary files /dev/null and b/promo/store/3-scope.png differ diff --git a/promo/store/4-help.png b/promo/store/4-help.png new file mode 100644 index 0000000..b1b2257 Binary files /dev/null and b/promo/store/4-help.png differ diff --git a/promo/store/5-rules.png b/promo/store/5-rules.png new file mode 100644 index 0000000..cf992cf Binary files /dev/null and b/promo/store/5-rules.png differ diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..2e1b721 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,83 @@ +// Launches the "holodeck": a throwaway browser profile with the extension +// loaded and a set of sample tabs across two windows, so every action has +// something to work on. +// +// make dev # Brave by default +// BROWSER=/path/to/chromium make dev +// +// The profile lives at ~/.holodeck// and is reused between runs, so +// settings you change persist. Delete it with `make dev-clean`. Nothing in +// ~/.holodeck is real; it exists to be trashed by humans and AI agents alike. +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const EXT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const CANDIDATES = [ + process.env.BROWSER, + '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/brave-browser' +].filter(Boolean); +const CHROME = CANDIDATES.find((c) => existsSync(c)); +if (!CHROME) { + console.error('No Chromium-based browser found. Set BROWSER=/path/to/binary'); + process.exit(2); +} +export const HOLODECK = process.env.HOLODECK || join(homedir(), '.holodeck'); +const browserSlug = basename(CHROME).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-browser$/, ''); +export const PROFILE = join(HOLODECK, browserSlug); + +// First launch: name the profile so every window's profile chip says where +// you are. Chrome reads profile.name from the Default profile's Preferences. +const prefs = join(PROFILE, 'Default', 'Preferences'); +if (!existsSync(prefs)) { + mkdirSync(dirname(prefs), { recursive: true }); + writeFileSync(prefs, JSON.stringify({ profile: { name: '🧪 holodeck' } })); +} + +const WINDOW_1 = [ + 'https://github.com/hacktoolkit/organize-tabs-chrome-extension/pull/8', + 'https://github.com/hacktoolkit/organize-tabs-chrome-extension/pull/8/files', + 'https://github.com/hacktoolkit/organize-tabs-chrome-extension/pull/7', + 'https://github.com/hacktoolkit/organize-tabs-chrome-extension/issues', + 'https://www.linkedin.com/in/jontsai/', + 'https://www.linkedin.com/in/jontsai/details/experience/', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://youtu.be/dQw4w9WgXcQ?si=abc', + 'https://en.wikipedia.org/wiki/Tab_(interface)?utm_source=test#History', + 'http://en.wikipedia.org/wiki/Tab_(interface)', + 'chrome://newtab/' +]; +const WINDOW_2 = [ + 'https://news.ycombinator.com/', + 'https://news.ycombinator.com/newest', + 'https://stackoverflow.com/questions/11227809/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array', + 'https://stackoverflow.com/q/11227809', + 'https://developer.chrome.com/docs/extensions/reference/api/tabGroups', + 'https://developer.chrome.com/docs/extensions/reference/api/tabs', + 'chrome://newtab/' +]; + +const args = [ + `--user-data-dir=${PROFILE}`, + `--load-extension=${EXT}`, + '--no-first-run', + '--no-default-browser-check', + ...WINDOW_1, + '--new-window', + ...WINDOW_2 +]; +const child = spawn(CHROME, args, { detached: true, stdio: 'ignore' }); +child.unref(); +console.log(`Launched ${CHROME}`); +console.log(`Holodeck profile: ${PROFILE}`); +console.log(''); +console.log('Two windows with sample tabs are open: duplicates by rule (PR subpages, LinkedIn'); +console.log('profile pages, YouTube short link, Stack Overflow /q/), by normalization (wikipedia'); +console.log('with utm/fragment/http), and blank tabs. Click the toolbar icon or right-click a page.'); +console.log('After editing source files, reload the extension at chrome://extensions.'); diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs new file mode 100644 index 0000000..3898481 --- /dev/null +++ b/scripts/e2e.mjs @@ -0,0 +1,348 @@ +// End-to-end smoke test. Launches a real Chromium-based browser headless with +// the extension loaded, then drives it over the DevTools protocol. +// +// make e2e # uses Brave by default (see BROWSER below) +// BROWSER=/path/to/chromium make e2e +// +// Google Chrome branded builds no longer honour --load-extension; use Brave, +// Chromium or Chrome for Testing. Network is disabled with a host-resolver +// rule so tab URLs stay exactly as seeded. +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// EXT= points the suite at another folder, e.g. an unzipped package +const EXT = process.env.EXT || resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const CANDIDATES = [ + process.env.BROWSER, + '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/chromium', + '/usr/bin/chromium-browser', + '/usr/bin/brave-browser' +].filter(Boolean); +const CHROME = CANDIDATES.find((c) => existsSync(c)); +if (!CHROME) { + console.error('No Chromium-based browser found. Set BROWSER=/path/to/binary'); + process.exit(2); +} +const PORT = Number(process.env.PORT) || 9337; +// Ephemeral profile under the holodeck (~/.holodeck), deleted when the run ends. +const HOLODECK = process.env.HOLODECK || join(homedir(), '.holodeck'); +mkdirSync(join(HOLODECK, 'tmp'), { recursive: true }); +const profile = mkdtempSync(join(HOLODECK, 'tmp', 'organize-tabs-e2e-')); + +const chrome = spawn(CHROME, [ + `--user-data-dir=${profile}`, + `--load-extension=${EXT}`, + `--remote-debugging-port=${PORT}`, + '--headless=new', + '--host-resolver-rules=MAP * ~NOTFOUND', + // CI containers have no user namespaces or /dev/shm to speak of + ...(process.env.CI ? ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] : []), + '--no-first-run', + '--no-default-browser-check', + 'about:blank' +], { stdio: ['ignore', 'ignore', 'pipe'] }); +let stderr = ''; +chrome.stderr.on('data', (d) => { stderr += d; }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +async function targets() { + const res = await fetch(`http://127.0.0.1:${PORT}/json/list`); + return res.json(); +} +async function waitFor(pred, label, tries = 60) { + for (let i = 0; i < tries; i++) { + try { + const t = (await targets()).find(pred); + if (t) return t; + } catch (e) { /* not up yet */ } + await sleep(250); + } + let list = []; + try { list = await targets(); } catch (e) { /* ignore */ } + throw new Error(`timeout waiting for ${label}; targets: ${JSON.stringify(list.map((t) => [t.type, t.url]))}`); +} + +class CDP { + constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map(); this.events = []; + ws.addEventListener('message', (m) => { + const msg = JSON.parse(m.data); + if (msg.id && this.pending.has(msg.id)) { const {res, rej} = this.pending.get(msg.id); this.pending.delete(msg.id); msg.error ? rej(new Error(msg.error.message)) : res(msg.result); } + else this.events.push(msg); + }); + } + static async connect(url) { const ws = new WebSocket(url); await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; }); return new CDP(ws); } + send(method, params = {}) { const id = ++this.id; this.ws.send(JSON.stringify({ id, method, params })); return new Promise((res, rej) => this.pending.set(id, { res, rej })); } + async eval(expression) { + const r = await this.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }); + if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || JSON.stringify(r.exceptionDetails)); + return r.result.value; + } +} + +let failures = 0; +function check(label, cond, detail = '') { + console.log(`${cond ? 'PASS' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`); + if (!cond) failures++; +} + +// Browsers ship built-in extensions with service workers of their own, so +// identify ours by manifest name rather than by URL shape. +async function findExtensionId(tries = 60) { + for (let i = 0; i < tries; i++) { + let list = []; + try { list = await targets(); } catch (e) { /* not up yet */ } + for (const t of list.filter((t) => t.type === 'service_worker' && t.url.endsWith('/src/background.js'))) { + try { + const cdp = await CDP.connect(t.webSocketDebuggerUrl); + const name = await cdp.eval('chrome.runtime.getManifest().name'); + cdp.ws.close(); + if (name === 'Organize Tabs') return new URL(t.url).host; + } catch (e) { /* try the next one */ } + } + await sleep(250); + } + throw new Error(`timeout waiting for the Organize Tabs service worker\n${stderr.split('\n').slice(-5).join('\n')}`); +} + +try { + const extId = await findExtensionId(); + console.log('extension id', extId); + + const browserInfo = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json(); + const browser = await CDP.connect(browserInfo.webSocketDebuggerUrl); + + // open the popup as a tab; it can drive chrome.runtime.sendMessage + const { targetId: popupId } = await browser.send('Target.createTarget', { url: `chrome-extension://${extId}/src/popup.html`, newWindow: true }); + await sleep(500); + const popupTarget = await waitFor((t) => t.url.endsWith('/src/popup.html'), 'popup target'); + const popup = await CDP.connect(popupTarget.webSocketDebuggerUrl); + await popup.send('Runtime.enable'); + const send = (msg) => popup.eval(`new Promise((res, rej) => chrome.runtime.sendMessage(${JSON.stringify(msg)}, (r) => r && r.ok ? res(r.result) : rej(new Error(r ? r.error : chrome.runtime.lastError?.message))))`); + const tabs = () => popup.eval(`chrome.tabs.query({})`); + const groups = () => popup.eval(`chrome.tabGroups.query({})`); + + // seed tabs: duplicates by rule, by normalization, plain, blank, pinned + const URLS = [ + 'https://github.com/dataro/app/pull/123', + 'https://github.com/dataro/app/pull/123/files', + 'https://github.com/dataro/app/pull/124', + 'https://github.com/dataro/other/issues/7', + 'https://www.linkedin.com/in/someone/', + 'https://www.linkedin.com/in/someone/details/experience/', + 'https://www.linkedin.com/in/other/', + 'https://example.com/page?utm_source=x#top', + 'http://example.com/page', + 'https://dataro.atlassian.net/browse/ENG-1', + 'https://news.ycombinator.com/', + 'chrome://newtab/' + ]; + const seedWindow = await popup.eval(`chrome.windows.create({ url: ${JSON.stringify(URLS)}, focused: false })`); + await sleep(1500); + await popup.eval(`chrome.tabs.query({ windowId: ${seedWindow.id}, index: 0 }).then(([t]) => chrome.tabs.update(t.id, { pinned: true }))`); + await sleep(300); + + const actions = await send({ type: 'actions' }); + check('actions list', actions.length === 16, `${actions.length} actions`); + + let s = await send({ type: 'stats' }); + check('stats counts duplicates', s.duplicates === 3, `duplicates=${s.duplicates} tabs=${s.tabs}`); + + const preview = await send({ type: 'preview', action: 'dedupe' }); + check('dedupe preview lists 3 tabs', preview.items.length === 3, preview.items.map((i) => i.url).join(' | ')); + const pinnedKept = !preview.items.some((i) => i.url === 'https://github.com/dataro/app/pull/123'); + check('dedupe keeps the pinned copy', pinnedKept); + + const opts = await send({ type: 'options', action: 'closeScope', args: { tabId: (await tabs()).find((t) => t.url.includes('pull/124')).id } }); + check('scope options offer domain/section/entity', opts.scopes.map((x) => x.id).join(',') === 'domain,section,entity', JSON.stringify(opts.scopes)); + check('entity scope counts unpinned PRs', opts.scopes[2].count === 2, `count=${opts.scopes[2].count}`); + check('domain scope excludes pinned', opts.scopes[0].count === 3, `count=${opts.scopes[0].count}`); + + // sort the seeded window; the pinned tab must stay at index 0 + await popup.eval(`chrome.windows.update(${seedWindow.id}, { focused: true })`); + await sleep(200); + const sortRes = await send({ type: 'run', action: 'sortWindow' }); + let after = (await tabs()).filter((t) => t.windowId === seedWindow.id).sort((a, b) => a.index - b.index); + check('sort keeps pinned tab first', after[0].pinned && after[0].url.includes('pull/123'), sortRes.message); + const unpinnedUrls = after.filter((t) => !t.pinned).map((t) => t.url); + const hosts = unpinnedUrls.map((u) => { try { return new URL(u).hostname.replace(/^www\./, ''); } catch { return u; } }); + const clustered = hosts.slice(0, -1).every((h, i) => h === hosts[i] || hosts.indexOf(h) === hosts.lastIndexOf(h) || hosts[i + 1] === h || !hosts.slice(i + 1).includes(h)); + check('sort clusters hosts', clustered, hosts.join(',')); + + const groupRes = await send({ type: 'run', action: 'groupByDomain' }); + const g = await groups(); + check('group by domain creates groups', g.length >= 3, `${groupRes.message}; groups=${g.map((x) => x.title).join(',')}`); + after = (await tabs()).filter((t) => t.windowId === seedWindow.id); + check('pinned tab not grouped', after.find((t) => t.pinned).groupId === -1); + + const sort2 = await send({ type: 'run', action: 'sortWindow' }); + const g2 = await groups(); + check('sort with groups keeps groups', g2.length === g.length, sort2.message); + + const runDedupe = await send({ type: 'run', action: 'dedupe' }); + check('dedupe closes 3', runDedupe.closed === 3, runDedupe.message); + s = await send({ type: 'stats' }); + check('no duplicates after dedupe', s.duplicates === 0 && s.undo && s.undo.count === 3, `undo=${JSON.stringify(s.undo)}`); + + const undo = await send({ type: 'run', action: 'undo' }); + await sleep(800); + s = await send({ type: 'stats' }); + check('undo reopens tabs', s.duplicates === 3, undo.message); + + const scopeRun = await send({ type: 'run', action: 'closeScope', args: { tabId: (await tabs()).find((t) => t.url.includes('pull/124')).id, scope: 'entity', keepCurrent: true } }); + check('close scope entity keeps current and pinned', scopeRun.closed === 1, scopeRun.message); + + const blank = await send({ type: 'run', action: 'closeBlank' }); + check('close blank', /Closed \d+ blank/.test(blank.message), blank.message); + + const park = await send({ type: 'preview', action: 'parkWindow', args: { windowId: seedWindow.id } }); + check('park preview lists unpinned web tabs', park.items.length > 0 && !park.items.some((i) => i.pinned), `${park.items.length} items`); + const parkRun = await send({ type: 'run', action: 'parkWindow', args: { windowId: seedWindow.id } }); + check('park closes tabs', parkRun.closed === park.items.length, parkRun.message); + const restore = await send({ type: 'run', action: 'restoreParked' }); + await sleep(800); + check('restore parked', /Restored \d+ tab/.test(restore.message), restore.message); + + // consolidate: groups must survive the move and merge into same-named groups + const winA = await popup.eval(`chrome.windows.create({ url: ['https://example.org/a', 'https://example.org/b', 'https://example.org/c', 'https://foo.test/x'], focused: false })`); + const winB = await popup.eval(`chrome.windows.create({ url: ['https://example.org/z', 'https://bar.test/y'], focused: false })`); + await sleep(1000); + const urlOf = (u) => `(await chrome.tabs.query({ url: ${JSON.stringify(u)} }))[0].id`; + await popup.eval(`(async () => { + const g1 = await chrome.tabs.group({ tabIds: [${await popup.eval(`(async () => ${urlOf('https://example.org/a')})()`)}, ${await popup.eval(`(async () => ${urlOf('https://example.org/b')})()`)}] }); + await chrome.tabGroups.update(g1, { title: 'alpha', color: 'blue' }); + const g2 = await chrome.tabs.group({ tabIds: [${await popup.eval(`(async () => ${urlOf('https://example.org/c')})()`)}, ${await popup.eval(`(async () => ${urlOf('https://foo.test/x')})()`)}] }); + await chrome.tabGroups.update(g2, { color: 'red' }); + const g3 = await chrome.tabs.group({ tabIds: [${await popup.eval(`(async () => ${urlOf('https://example.org/z')})()`)}] }); + await chrome.tabGroups.update(g3, { title: 'alpha', color: 'green' }); + })()`); + await sleep(300); + await popup.eval(`chrome.windows.update(${winB.id}, { focused: true })`); + const before = (await popup.eval(`chrome.windows.getAll({ windowTypes: ['normal'] })`)).length; + const cons = await send({ type: 'run', action: 'consolidateAll' }); + await sleep(500); + const afterWin = (await popup.eval(`chrome.windows.getAll({ windowTypes: ['normal'] })`)).length; + check('consolidate reduces windows', afterWin < before, `${before} -> ${afterWin}; ${cons.message}`); + const pinnedTab = (await tabs()).find((t) => t.url === 'https://github.com/dataro/app/pull/123'); + check('consolidate keeps pinned', pinnedTab && pinnedTab.pinned && pinnedTab.index === 0, JSON.stringify({ pinned: pinnedTab?.pinned, index: pinnedTab?.index })); + let allGroups = await groups(); + let allTabs = await tabs(); + const alpha = allGroups.filter((g) => g.title === 'alpha'); + const alphaTabs = alpha.length === 1 ? allTabs.filter((t) => t.groupId === alpha[0].id).map((t) => t.url).sort() : []; + check('consolidate merges same-named groups', alpha.length === 1 && alphaTabs.join() === ['https://example.org/a', 'https://example.org/b', 'https://example.org/z'].join(), `${alpha.length} alpha groups: ${alphaTabs.join(', ')}`); + const red = allGroups.find((g) => g.color === 'red' && !g.title); + const redTabs = red ? allTabs.filter((t) => t.groupId === red.id).map((t) => t.url).sort() : []; + check('consolidate keeps untitled group intact', red && redTabs.join() === ['https://example.org/c', 'https://foo.test/x'].join(), redTabs.join(', ')); + check('all moved tabs live in one window', new Set(allTabs.filter((t) => /example\.org|foo\.test|bar\.test/.test(t.url)).map((t) => t.windowId)).size === 1); + + const split = await send({ type: 'run', action: 'splitByDomain' }); + await sleep(800); + check('split by domain', /Split \d+ tabs into \d+ windows/.test(split.message), split.message); + allGroups = await groups(); + allTabs = await tabs(); + const alphaAfter = allGroups.filter((g) => g.title === 'alpha'); + const alphaAfterTabs = alphaAfter.length === 1 ? allTabs.filter((t) => t.groupId === alphaAfter[0].id).map((t) => t.url).sort() : []; + check('split keeps the alpha group together', alphaAfter.length === 1 && alphaAfterTabs.join() === ['https://example.org/a', 'https://example.org/b', 'https://example.org/z'].join(), alphaAfterTabs.join(', ')); + const c = allTabs.find((t) => t.url === 'https://example.org/c'); + const x = allTabs.find((t) => t.url === 'https://foo.test/x'); + const cg = c && c.groupId !== -1 ? allGroups.find((g) => g.id === c.groupId) : null; + const xg = x && x.groupId !== -1 ? allGroups.find((g) => g.id === x.groupId) : null; + check('split keeps tabs of a divided group in same-colored groups', cg && xg && cg.color === 'red' && xg.color === 'red' && c.windowId !== x.windowId, JSON.stringify({ c: cg?.color, x: xg?.color })); + check('no blank tabs left by split', allTabs.filter((t) => t.url === 'chrome://newtab/' && allTabs.filter((o) => o.windowId === t.windowId).length > 1).length === 0); + + const focus = await send({ type: 'run', action: 'focusAllWindows' }); + check('focus all windows', /Brought \d+ window/.test(focus.message), focus.message); + + // park a window that has no pinned tab: the window must survive, no error + const winP = await popup.eval(`chrome.windows.create({ url: ['https://park.test/a', 'https://park.test/b'], focused: false })`); + await sleep(800); + const parkNoPin = await send({ type: 'run', action: 'parkWindow', args: { windowId: winP.id } }); + const winPAfter = await popup.eval(`chrome.windows.get(${winP.id}, { populate: true }).catch(() => null)`); + check('park without pinned tabs keeps the window alive', parkNoPin.closed === 2 && winPAfter && winPAfter.tabs.length === 1, `${parkNoPin.message}; window=${winPAfter ? winPAfter.tabs.length + ' tabs' : 'gone'}`); + await send({ type: 'run', action: 'restoreParked' }); + await sleep(600); + + // an empty window must not be chosen as the media window + const winE = await popup.eval(`chrome.windows.create({ focused: false })`); + await sleep(300); + + // meetings and media: pull them out, then consolidate must leave them alone + const winM = await popup.eval(`chrome.windows.create({ url: ['https://meet.google.com/abc-defg-hij', 'https://example.net/doc', 'https://www.youtube.com/watch?v=abc123'], focused: false })`); + await popup.eval(`chrome.windows.create({ url: ['https://example.net/other'], focused: false })`); + await sleep(1000); + // a video popped out into its own popup window must be skipped, not fatal + await popup.eval(`chrome.windows.create({ url: 'https://www.youtube.com/watch?v=popout1', type: 'popup', focused: false })`); + await sleep(500); + const pull = await send({ type: 'run', action: 'pullMedia' }); + check('pull media reports popup-window media instead of failing', /1 tab in popup or app windows left as is/.test(pull.message), pull.message); + await sleep(500); + let all = await tabs(); + const meet = all.find((t) => t.url.startsWith('https://meet.google.com/')); + const yt = all.find((t) => t.url === 'https://www.youtube.com/watch?v=abc123'); + const mediaWin = all.filter((t) => t.windowId === meet.windowId); + check('pull media gathers meeting and player into one window', meet.windowId === yt.windowId && mediaWin.length === 2 && meet.windowId !== winM.id, `${pull.message}; ${mediaWin.map((t) => t.url).join(', ')}`); + check('pull media focuses the meeting', meet.active); + check('pull media did not pick the empty window', meet.windowId !== winE.id); + await popup.eval(`chrome.windows.remove(${winE.id}).catch(() => {})`); + check('pull media leaves non-media tabs behind', all.find((t) => t.url === 'https://example.net/doc').windowId === winM.id); + const cons2 = await send({ type: 'run', action: 'consolidateAll' }); + await sleep(500); + all = await tabs(); + const mediaWinAfter = all.filter((t) => t.windowId === meet.windowId); + check('consolidate leaves the media window alone', mediaWinAfter.length === 2 && /left 1 media window alone/.test(cons2.message), `${cons2.message}; ${mediaWinAfter.length} tabs`); + const docTab = all.find((t) => t.url === 'https://example.net/doc'); + check('consolidate still merged the other windows', docTab.windowId !== meet.windowId && all.find((t) => t.url === 'https://example.net/other').windowId === docTab.windowId); + + // auto-dedupe + await popup.eval(`chrome.storage.sync.set({ settings: { autoDedupe: true } })`); + await send({ type: 'settingsChanged' }); + const countBefore = (await tabs()).length; + await popup.eval(`chrome.tabs.create({ url: 'https://github.com/dataro/app/pull/124/checks', active: false })`); + await sleep(1500); + const countAfter = (await tabs()).length; + check('auto-dedupe closes the new duplicate', countAfter === countBefore, `${countBefore} -> ${countAfter}`); + // with auto-dedupe on, Undo must reopen a duplicate and leave it open + const undoWithAuto = await send({ type: 'run', action: 'undo' }); + await sleep(1500); + const checksTab = (await tabs()).filter((t) => t.url === 'https://github.com/dataro/app/pull/124/checks'); + check('undo survives auto-dedupe', checksTab.length === 1, `${undoWithAuto.message}; found ${checksTab.length}`); + await popup.eval(`chrome.storage.sync.set({ settings: { autoDedupe: false } })`); + await send({ type: 'settingsChanged' }); + + // options page loads without errors + const { targetId: optId } = await browser.send('Target.createTarget', { url: `chrome-extension://${extId}/src/options.html` }); + await sleep(800); + const optTarget = await waitFor((t) => t.url.endsWith('/src/options.html'), 'options target'); + const opt = await CDP.connect(optTarget.webSocketDebuggerUrl); + const ruleRows = await opt.eval(`document.querySelectorAll('#rule-list .rule').length`); + check('options page renders built-in rules', ruleRows === 19, `${ruleRows} rows`); + const helpRows = await opt.eval(`new Promise(r => setTimeout(() => r(document.querySelectorAll('#help-list .help-item').length), 500))`); + check('options help lists actions', helpRows === 16, `${helpRows} rows`); + const exported = await opt.eval(`(async () => { const m = await import('./lib/settings.js'); const l = await m.loadSettings(); return JSON.stringify(m.exportBundle(l)).length; })()`); + check('export bundle builds', exported > 1000, `${exported} bytes`); + + // popup renders + const popupButtons = await popup.eval(`document.querySelectorAll('.action').length`); + check('popup renders action buttons', popupButtons === 16, `${popupButtons} buttons`); + + // any console errors in the service worker? + const swTarget = await waitFor((t) => t.type === 'service_worker' && t.url.startsWith(`chrome-extension://${extId}/`), 'sw'); + const swc = await CDP.connect(swTarget.webSocketDebuggerUrl); + await swc.send('Runtime.enable'); + await sleep(300); + const errs = swc.events.filter((e) => e.method === 'Runtime.exceptionThrown'); + check('no uncaught exceptions in service worker (since attach)', errs.length === 0, JSON.stringify(errs).slice(0, 300)); +} catch (e) { + console.error('E2E ERROR', e); + failures++; +} finally { + chrome.kill('SIGKILL'); + await sleep(300); + rmSync(profile, { recursive: true, force: true }); + console.log(failures ? `\n${failures} FAILURE(S)` : '\nALL PASSED'); + process.exit(failures ? 1 : 0); +} diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs new file mode 100644 index 0000000..e06f71f --- /dev/null +++ b/scripts/screenshots.mjs @@ -0,0 +1,196 @@ +// Renders Chrome Web Store screenshots (1280x800) and README images from the +// holodeck: launches a headless browser with the extension, seeds tabs, +// captures the popup and options page, then composes each capture onto a +// 1280x800 stage with a caption. +// +// make screenshots # writes promo/*.png and promo/store/*.png +import { spawn } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const EXT = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const OUT = join(EXT, 'promo'); +const STORE = join(OUT, 'store'); +const CANDIDATES = [ + process.env.BROWSER, + '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/usr/bin/chromium', + '/usr/bin/chromium-browser' +].filter(Boolean); +const CHROME = CANDIDATES.find((c) => existsSync(c)); +if (!CHROME) { + console.error('No Chromium-based browser found. Set BROWSER=/path/to/binary'); + process.exit(2); +} +const PORT = Number(process.env.PORT) || 9339; +const HOLODECK = process.env.HOLODECK || join(homedir(), '.holodeck'); +mkdirSync(join(HOLODECK, 'tmp'), { recursive: true }); +mkdirSync(STORE, { recursive: true }); +const profile = mkdtempSync(join(HOLODECK, 'tmp', 'organize-tabs-shots-')); + +const chrome = spawn(CHROME, [ + `--user-data-dir=${profile}`, + `--load-extension=${EXT}`, + `--remote-debugging-port=${PORT}`, + '--headless=new', + '--host-resolver-rules=MAP * ~NOTFOUND', + '--no-first-run', + '--no-default-browser-check', + ...(process.env.CI ? ['--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] : []), + 'about:blank' +], { stdio: 'ignore' }); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +async function targets() { return (await fetch(`http://127.0.0.1:${PORT}/json/list`)).json(); } +async function waitFor(pred, tries = 60) { + for (let i = 0; i < tries; i++) { + try { const t = (await targets()).find(pred); if (t) return t; } catch (e) { /* not up */ } + await sleep(250); + } + throw new Error('timeout waiting for target'); +} +class CDP { + constructor(ws) { + this.ws = ws; this.id = 0; this.pending = new Map(); + ws.addEventListener('message', (m) => { + const msg = JSON.parse(m.data); + if (msg.id && this.pending.has(msg.id)) { + const { res, rej } = this.pending.get(msg.id); this.pending.delete(msg.id); + msg.error ? rej(new Error(msg.error.message)) : res(msg.result); + } + }); + } + static async connect(url) { const ws = new WebSocket(url); await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; }); return new CDP(ws); } + send(m, p = {}) { const id = ++this.id; this.ws.send(JSON.stringify({ id, method: m, params: p })); return new Promise((res, rej) => this.pending.set(id, { res, rej })); } + async eval(e) { const r = await this.send('Runtime.evaluate', { expression: e, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails)); return r.result.value; } + async shot(file, { width, height, scale = 2, dark = false }) { + await this.send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: scale, mobile: false }); + await this.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: dark ? 'dark' : 'light' }] }); + await sleep(350); + const r = await this.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); + writeFileSync(file, Buffer.from(r.data, 'base64')); + return file; + } +} +async function findExtensionId() { + for (let i = 0; i < 60; i++) { + let list = []; + try { list = await targets(); } catch (e) { /* not up */ } + for (const t of list.filter((t) => t.type === 'service_worker' && t.url.endsWith('/src/background.js'))) { + try { + const cdp = await CDP.connect(t.webSocketDebuggerUrl); + const name = await cdp.eval('chrome.runtime.getManifest().name'); + cdp.ws.close(); + if (name === 'Organize Tabs') return new URL(t.url).host; + } catch (e) { /* next */ } + } + await sleep(250); + } + throw new Error('extension not found'); +} + +// A 1280x800 stage: dark gradient, headline, caption, and the capture scaled +// to fit on the right (or centred for wide captures). +function stageHtml({ png, title, caption, wide = false }) { + const data = readFileSync(png).toString('base64'); + return `
+

${title}

${caption}

+
+
Organize Tabs
+
`; +} + +try { + const extId = await findExtensionId(); + const info = await (await fetch(`http://127.0.0.1:${PORT}/json/version`)).json(); + const browser = await CDP.connect(info.webSocketDebuggerUrl); + + const URLS = [ + 'https://github.com/dataro/app/pull/123', + 'https://github.com/dataro/app/pull/123/files', + 'https://github.com/dataro/app/pull/124', + 'https://github.com/dataro/app/issues/7', + 'https://www.linkedin.com/in/someone/', + 'https://www.linkedin.com/in/someone/details/experience/', + 'https://www.linkedin.com/in/other/', + 'https://dataro.atlassian.net/browse/ENG-1', + 'https://dataro.atlassian.net/browse/ENG-2', + 'https://meet.google.com/abc-defg-hij', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://news.ycombinator.com/', + 'https://example.com/page?utm_source=x', + 'http://example.com/page', + 'chrome://newtab/' + ]; + await browser.send('Target.createTarget', { url: `chrome-extension://${extId}/src/popup.html`, newWindow: true, width: 340, height: 640 }); + const popupT = await waitFor((t) => t.url.endsWith('/src/popup.html')); + const popup = await CDP.connect(popupT.webSocketDebuggerUrl); + await popup.eval(`chrome.windows.create({ url: ${JSON.stringify(URLS)}, focused: false })`); + await sleep(1200); + // make the current tab a PR so the scope panel has an entity option + await popup.eval(`(async () => { const [t] = await chrome.tabs.query({ url: 'https://github.com/dataro/app/pull/124' }); await chrome.tabs.update(t.id, { active: true }); await chrome.windows.update(t.windowId, { focused: true }); })()`); + await popup.eval('location.reload()'); + await sleep(1200); + + const P = { width: 340, height: 640 }; + const shots = {}; + shots.popup = await popup.shot(join(OUT, 'popup.png'), P); + shots.popupDark = await popup.shot(join(OUT, 'popup-dark.png'), { ...P, dark: true }); + await popup.eval(`document.querySelector('#help-toggle').click()`); await sleep(300); + shots.help = await popup.shot(join(OUT, 'popup-help.png'), P); + await popup.eval(`document.querySelector('#help-toggle').click()`); await sleep(200); + await popup.eval(`[...document.querySelectorAll('.action')].find(b => b.textContent.includes('Deduplicate')).click()`); await sleep(900); + shots.preview = await popup.shot(join(OUT, 'preview.png'), P); + await popup.eval(`document.querySelector('#panel-cancel').click()`); await sleep(200); + await popup.eval(`[...document.querySelectorAll('.action')].find(b => b.textContent.includes('Like This')).click()`); await sleep(900); + await popup.eval(`[...document.querySelectorAll('.scope input')].at(-1).click()`); await sleep(700); + shots.scope = await popup.shot(join(OUT, 'scope.png'), P); + await popup.eval(`document.querySelector('#panel-cancel').click()`); + + await browser.send('Target.createTarget', { url: `chrome-extension://${extId}/src/options.html#rules`, newWindow: true, width: 1180, height: 760 }); + const optT = await waitFor((t) => t.url.includes('/src/options.html')); + const opt = await CDP.connect(optT.webSocketDebuggerUrl); + await sleep(900); + await opt.eval(`document.querySelector('#test-url').value = 'https://github.com/dataro/app/pull/123/files'; document.querySelector('#test-url').dispatchEvent(new Event('input')); document.querySelector('#rules').scrollIntoView(); window.scrollBy(0, -16)`); + await sleep(300); + shots.options = await opt.shot(join(OUT, 'options.png'), { width: 1180, height: 760, scale: 1 }); + + // compose store screenshots + const stages = [ + { file: '1-popup.png', png: shots.popup, title: 'Every tab action, one click away', caption: 'Group, sort, deduplicate, close by scope, park to bookmarks, pull meetings into their own window. Live counts show what each action will touch.' }, + { file: '2-dedupe.png', png: shots.preview, title: 'Duplicates, even when the URLs differ', caption: 'A pull request and its Files tab, a Jira issue and its board link, a LinkedIn profile and its Experience page: one key, one tab. Preview before anything closes, undo after.' }, + { file: '3-scope.png', png: shots.scope, title: 'Close tabs like this one', caption: 'Same domain, same section of the site, or the same kind of page: every open pull request, every profile. Keep the one you are on.' }, + { file: '4-help.png', png: shots.help, title: 'Help where you need it', caption: 'Flip on descriptions and shortcuts inline. Pinned tabs never move; tab groups stay together and travel between windows intact.' }, + { file: '5-rules.png', png: shots.options, title: 'Your rules, synced everywhere', caption: 'Edit the duplicate rules, test a URL live, export and import JSON, or point at a rules file in your dotfiles. Settings follow your browser profile.', wide: true } + ]; + for (const st of stages) { + const html = join(profile, `${st.file}.html`); + writeFileSync(html, stageHtml(st)); + const { targetId } = await browser.send('Target.createTarget', { url: `file://${html}`, newWindow: true, width: 1280, height: 800 }); + const t = await waitFor((x) => x.id === targetId || x.url === `file://${html}`); + const page = await CDP.connect(t.webSocketDebuggerUrl); + await sleep(500); + await page.shot(join(STORE, st.file), { width: 1280, height: 800, scale: 1, dark: true }); + page.ws.close(); + console.log('wrote', join('promo/store', st.file)); + } + console.log('wrote', Object.values(shots).map((f) => f.replace(EXT + '/', '')).join(', ')); +} finally { + chrome.kill('SIGKILL'); + await sleep(300); + rmSync(profile, { recursive: true, force: true }); +} diff --git a/src/background.js b/src/background.js new file mode 100644 index 0000000..df63b32 --- /dev/null +++ b/src/background.js @@ -0,0 +1,305 @@ +// Service worker. All tab operations run here so they survive the popup +// closing (creating or focusing a window closes the popup). + +import { ACTIONS, ACTION_BY_ID, closeTabs, forgetOwnTab, isOwnTab, loadContext, stats } from './lib/actions.js'; +import { clearRemoteRules, fetchRemoteRules, recordRemoteError } from './lib/settings.js'; +import { canonicalize, isBlankUrl } from './lib/url.js'; + +const MENU_ROOT = 'organizeTabs'; +const REMOTE_ALARM = 'refreshRemoteRules'; +const GROUP_TITLES = { + organize: 'Organize', + cleanup: 'Clean up', + windows: 'Windows' +}; + +// ----- MESSAGES FROM POPUP / OPTIONS -------------------- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + handleMessage(message) + .then((result) => sendResponse({ ok: true, result })) + .catch((error) => { + console.error('Organize Tabs action failed', message, error); + sendResponse({ ok: false, error: error.message || String(error) }); + }); + return true; // keep the channel open for the async response +}); + +async function handleMessage(message) { + const { type, action: actionId, args = {} } = message || {}; + const action = actionId ? ACTION_BY_ID[actionId] : null; + switch (type) { + case 'stats': + return stats(); + case 'actions': + return ACTIONS.map(({ id, name, group, help, preview, options }) => ({ + id, + name, + group, + help, + hasPreview: typeof preview === 'function', + hasOptions: typeof options === 'function' + })); + case 'options': + if (!action || !action.options) { + throw new Error(`No options for ${actionId}`); + } + return action.options(args); + case 'preview': + if (!action || !action.preview) { + throw new Error(`No preview for ${actionId}`); + } + return action.preview(args); + case 'run': { + if (!action) { + throw new Error(`Unknown action ${actionId}`); + } + const result = await action.run(args); + scheduleBadge(); + return result; + } + case 'settingsChanged': + await rebuildContextMenus(); + await scheduleRemoteRefresh(); + scheduleBadge(); + return true; + case 'fetchRemoteRules': + return refreshRemoteRules(true); + default: + throw new Error(`Unknown message type ${type}`); + } +} + +// ----- CONTEXT MENUS -------------------- + +let menuBuild = Promise.resolve(); + +function rebuildContextMenus() { + // serialize: onInstalled and onStartup can both fire at launch + menuBuild = menuBuild.then(buildContextMenus, buildContextMenus); + return menuBuild; +} + +async function buildContextMenus() { + await chrome.contextMenus.removeAll(); + chrome.contextMenus.create({ id: MENU_ROOT, title: 'Organize Tabs', contexts: ['all'] }); + let lastGroup = null; + for (const action of ACTIONS) { + if (lastGroup && lastGroup !== action.group) { + chrome.contextMenus.create({ + id: `sep-${action.group}`, + parentId: MENU_ROOT, + type: 'separator', + contexts: ['all'] + }); + } + lastGroup = action.group; + chrome.contextMenus.create({ + id: action.id, + parentId: MENU_ROOT, + title: action.name, + contexts: ['all'] + }); + } +} + +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + const action = ACTION_BY_ID[info.menuItemId]; + if (!action) { + return; + } + try { + // The context menu has no preview step; it acts on the clicked tab. + await action.run({ tabId: tab?.id, windowId: tab?.windowId, scope: 'domain' }); + } catch (e) { + console.error(`Context menu action ${action.id} failed`, e); + } + scheduleBadge(); +}); + +// ----- KEYBOARD COMMANDS -------------------- + +chrome.commands.onCommand.addListener(async (command, tab) => { + const action = ACTION_BY_ID[command]; + if (!action) { + return; + } + try { + await action.run({ tabId: tab?.id, windowId: tab?.windowId, scope: 'domain' }); + } catch (e) { + console.error(`Command ${command} failed`, e); + } + scheduleBadge(); +}); + +// ----- AUTO-DEDUPE -------------------- + +// Tabs created in the last few seconds are candidates: if their URL resolves to +// something already open, close the new tab and focus the existing one. +const recentlyCreated = new Map(); +const AUTO_DEDUPE_WINDOW_MS = 8000; + +chrome.tabs.onCreated.addListener((tab) => { + recentlyCreated.set(tab.id, Date.now()); + for (const [id, at] of recentlyCreated) { + if (Date.now() - at > AUTO_DEDUPE_WINDOW_MS) { + recentlyCreated.delete(id); + } + } +}); + +chrome.tabs.onRemoved.addListener((tabId) => { + recentlyCreated.delete(tabId); + forgetOwnTab(tabId); + scheduleBadge(); +}); + +chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (changeInfo.url || changeInfo.status === 'complete') { + scheduleBadge(); + } + if (!changeInfo.url || isBlankUrl(changeInfo.url)) { + return; + } + if (isOwnTab(tabId)) { + // Undo and Restore reopen tabs on purpose; never auto-close those + recentlyCreated.delete(tabId); + return; + } + const createdAt = recentlyCreated.get(tabId); + if (!createdAt || Date.now() - createdAt > AUTO_DEDUPE_WINDOW_MS) { + return; + } + const ctx = await loadContext(); + if (!ctx.settings.autoDedupe) { + return; + } + const { key } = canonicalize(changeInfo.url, ctx.compiled, ctx.settings.normalization); + const all = await chrome.tabs.query({}); + const existing = all.find( + (t) => + t.id !== tabId && + t.incognito === tab.incognito && + canonicalize(t.url || t.pendingUrl || '', ctx.compiled, ctx.settings.normalization).key === key + ); + if (!existing) { + return; + } + recentlyCreated.delete(tabId); + await closeTabs([{ ...tab, url: changeInfo.url }], 'Auto-dedupe'); + try { + await chrome.tabs.update(existing.id, { active: true }); + await chrome.windows.update(existing.windowId, { focused: true }); + } catch (e) { + // the existing tab may have gone away in the meantime + } +}); + +// ----- BADGE -------------------- + +let badgeTimer = null; + +function scheduleBadge() { + if (badgeTimer) { + clearTimeout(badgeTimer); + } + badgeTimer = setTimeout(updateBadge, 750); +} + +async function updateBadge() { + badgeTimer = null; + try { + const ctx = await loadContext(); + if (!ctx.settings.showBadge) { + await chrome.action.setBadgeText({ text: '' }); + return; + } + const s = await stats(); + await chrome.action.setBadgeBackgroundColor({ color: '#5b6cff' }); + await chrome.action.setBadgeText({ text: s.duplicates > 0 ? String(s.duplicates) : '' }); + await chrome.action.setTitle({ + title: `Organize Tabs: ${s.tabs} tabs, ${s.duplicates} duplicates` + }); + } catch (e) { + console.warn('badge update failed', e); + } +} + +// ----- REMOTE RULES -------------------- + +// Keeps the alarm in step with the settings without restarting its countdown +// on every save, drops the cache when the URL is removed or changed, and +// fetches right away when the cache is missing or older than the period. +async function scheduleRemoteRefresh() { + const ctx = await loadContext(); + const url = ctx.settings.remoteRulesUrl; + const hours = Math.max(1, Number(ctx.settings.remoteRefreshHours) || 24); + const period = hours * 60; + const existing = await chrome.alarms.get(REMOTE_ALARM); + if (!url) { + if (existing) { + await chrome.alarms.clear(REMOTE_ALARM); + } + if (ctx.remoteRules.length > 0 || ctx.remoteRulesMeta) { + await clearRemoteRules(); + scheduleBadge(); + } + return; + } + if (!existing || existing.periodInMinutes !== period) { + chrome.alarms.create(REMOTE_ALARM, { periodInMinutes: period }); + } + const meta = ctx.remoteRulesMeta; + const stale = !meta || meta.url !== url || !meta.fetchedAt || Date.now() - meta.fetchedAt > period * 60000; + if (stale) { + if (meta && meta.url !== url) { + await clearRemoteRules(); + } + await refreshRemoteRules(); + } +} + +async function refreshRemoteRules(force = false) { + const ctx = await loadContext(); + const url = ctx.settings.remoteRulesUrl; + if (!url) { + return null; + } + try { + const meta = await fetchRemoteRules(url); + scheduleBadge(); + return meta; + } catch (e) { + const meta = await recordRemoteError(url, e.message || e); + if (force) { + throw new Error(`Could not fetch rules: ${meta.error}`); + } + return meta; + } +} + +chrome.alarms.onAlarm.addListener((alarm) => { + if (alarm.name === REMOTE_ALARM) { + refreshRemoteRules(); + } +}); + +// ----- LIFECYCLE -------------------- + +chrome.runtime.onInstalled.addListener(async (details) => { + await rebuildContextMenus(); + await scheduleRemoteRefresh(); + scheduleBadge(); + if (details.reason === 'install') { + chrome.runtime.openOptionsPage(); + } +}); + +chrome.runtime.onStartup.addListener(() => { + rebuildContextMenus(); + scheduleRemoteRefresh(); + scheduleBadge(); +}); + +chrome.windows.onRemoved.addListener(scheduleBadge); +scheduleBadge(); diff --git a/src/js/options.js b/src/js/options.js deleted file mode 100644 index 62a7c94..0000000 --- a/src/js/options.js +++ /dev/null @@ -1,10 +0,0 @@ -$(function() { - function initEventHandlers() { - } - - function init() { - } - - initEventHandlers(); - init(); -}); diff --git a/src/js/organize_tabs.js b/src/js/organize_tabs.js deleted file mode 100644 index b0bcbc0..0000000 --- a/src/js/organize_tabs.js +++ /dev/null @@ -1,520 +0,0 @@ -console.log('Organize Tabs Extension is loaded'); - -// ----- CONSTANTS -------------------- - -const HOST_REGEX = /https?:\/\/(?[^\/]*).*/; -const NEW_TAB_URL = 'chrome://newtab/'; - -const ACTIONS = [ - { - name: 'Collate Tabs', - id: 'collateTabs', - cls: 'collate-tabs', - callback: handleCollateTabsClicked - }, - { - name: 'Consolidate All Tabs', - id: 'consolidateAllTabs', - cls: 'consolidate-all-tabs', - callback: handleConsolidateAllTabsClicked - }, - { - name: 'Consolidate Pinned vs Unpinned Tabs', - id: 'consolidatePinnedTabs', - cls: 'consolidate-pinned-tabs', - callback: handleConsolidatePinnedTabsClicked - }, - { - name: 'Deduplicate Tabs', - id: 'deduplicateTabs', - cls: 'deduplicate-tabs', - callback: handleDeduplicateTabsClicked - }, - { - name: 'Sort Tabs in Window', - id: 'sortWindowTabs', - cls: 'sort-window-tabs', - callback: handleSortWindowTabsClicked - }, - { - name: 'Close All Tabs from this Domain', - id: 'closeDomainTabs', - cls: 'close-domain-tabs', - callback: handleCloseDomainTabsClicked - }, - // { - // name: 'Close Orphaned Tabs', - // id: 'closeOrphans', - // cls: 'close-orphans', - // callback: handleCloseOrphansClicked - // }, - { - name: 'Close Blank Tabs', - id: 'closeBlankTabs', - cls: 'close-blank-tabs', - callback: handleCloseBlankTabsClicked - }, - { - name: 'Bring All Windows To Front', - id: 'focusAllWindows', - cls: 'focus-all-windows', - handleFocusAllWindowsClicked - } -]; - -const ACTION_CALLBACKS = {}; -ACTIONS.forEach((action) => { - ACTION_CALLBACKS[action.id] = action.callback; -}); - -// ----- CORE VARIABLES -------------------- - -let NUM_WINDOWS = null; -let NUM_WINDOWS_WITH_RESOLVED_TABS = null; -let PINNED_TABS = []; -let WINDOW_TABS = {}; -let BUCKETS = {}; -let HOUSEKEEPING_DONE = false; - -// ----- OPTIONS -------------------- - -// TODO: make this a configurable option -let SKIP_WINDOWS_WITH_PINNED_TABS = false; - -// ----- CORE FUNCTIONS -------------------- - -function resetVariables() { - NUM_WINDOWS = 0; - NUM_WINDOWS_WITH_RESOLVED_TABS = 0; - - PINNED_TABS = []; - WINDOW_TABS = {}; - BUCKETS = {}; - - HOUSEKEEPING_DONE = false; -} - -function collateTabs() { - // Groups all tabs into separate windows, grouped by hostname, and sorted by URL. Ignores windows with pinned tabs. - // - // Algorithm: - // 1. Go through all the windows/tabs in WINDOW_TABS, and build buckets of tabs keyed by hostname - // 2. Loop through every bucket. - // a. For every bucket with multiple tabs, create a new window for that bucket and move all tabs to that new window, ordered by the URL - // b. For every bucket with single tabs, move all of those tabs to a new window - // 3. Close all old windows - - // Build buckets of tabs keyed by hostname - Object.keys(WINDOW_TABS).forEach((windowId) => { - const tabs = WINDOW_TABS[windowId]; - tabs.forEach((tab) => { - const match = HOST_REGEX.exec(tab.url); - if (match) { - const host = match.groups.host; - - if (typeof BUCKETS[host] === 'undefined') { - BUCKETS[host] = []; - } - - BUCKETS[host].push(tab); - } else { - // skip non-matching URLS, e.g. chrome://newtab/ - } - }); - }); - - // Sort tabs by URL in each bucket - // Identify buckets with single tabs - const singleTabs = []; - - Object.keys(BUCKETS).forEach((host) => { - const tabs = BUCKETS[host]; - if (tabs.length === 1) { - singleTabs.push(tabs[0]); - } else { - const orderedTabs = tabs.sort(compareTabUrls); - - // Create a new window and move tabs over - moveTabsToNewWindow(orderedTabs).then(function (movedTabs) { - closeBlankTabs(); - }); - } - }); - - // Create a new window for single tabs, and move over, and include a callback - // to close old windows - moveTabsToNewWindow(singleTabs).then(function (movedTabs) { - closeBlankTabs(); - }); -} - -function consolidateTabs() { - // Combines all tabs across all windows into one window, sorted by URL. Ignores windows with pinned tabs. - // - // Algorithm: - // 1. Go through all the windows/tabs in WINDOW_TABS, and gather a list of all tabs - // 2. Move all tabs to a new window, ordered by the URL - // 3. Close all old windows - - const combinedTabs = []; - - // Combine unpinned tabs for all windows, ordered by `windowId` - const windowIds = Object.keys(WINDOW_TABS); - windowIds.sort(); - windowIds.forEach((windowId) => { - const tabs = WINDOW_TABS[windowId]; - tabs.forEach((tab) => { - const match = HOST_REGEX.exec(tab.url); - if (match) { - // include tabs with actual webpages - combinedTabs.push(tab); - } else { - // skip non-matching URLS, e.g. chrome://newtab/ - } - }); - }); - - // Sort unpinned tabs by URL - const orderedCombinedTabs = combinedTabs.sort(compareTabUrls); - - // Combine pinned tabs with unpinned tabs - const orderedTabs = PINNED_TABS.concat(orderedCombinedTabs); - - // Create a new window and move tabs over - moveTabsToNewWindow(orderedTabs) - .then(function (movedTabs) { - return closeBlankTabs(); - }) - .then(function (closedTabs) { - repinTabs(); - }); -} - -function moveTabsToNewWindow(tabs) { - const tabIds = tabs.map((tab) => { - return tab.id; - }); - - // https://developer.chrome.com/extensions/windows#method-create - const createData = {}; - return chrome.windows.create(createData).then(function (window) { - // https://developer.chrome.com/extensions/tabs#method-move - const moveProperties = { - windowId: window.id, - index: -1 - }; - - // Open bug in Chromium: https://bugs.chromium.org/p/chromium/issues/detail?id=876460&q=chrome.tabs.move&can=2 - // When moving pinned tabs via API, it becomes unpinned. - // Also, when `tabIds` includes pinned tabs, the promise/callback fails to complete - return chrome.tabs.move(tabIds, moveProperties); - }); -} - -function deduplicateTabs() { - chrome.tabs.query({}, function (tabs) { - const VISITED_URLS = {}; - tabs.forEach((tab) => { - // grab the base URL without the fragment identifier - const url = tab.url.split('#')[0]; - if (typeof VISITED_URLS[url] === 'undefined') { - VISITED_URLS[url] = true; - } else { - // already visited, close the tab - chrome.tabs.remove(tab.id); - } - }); - }); -} - -function compareTabUrls(a, b) { - const result = a.url < b.url ? -1 : a.url > b.url ? 1 : 0; - return result; -} - -function sortWindowTabs() { - chrome.tabs.query({ currentWindow: true }, function (tabs) { - const orderedTabs = tabs.sort(compareTabUrls); - - const tabIds = orderedTabs.map((tab) => { - return tab.id; - }); - - const moveProperties = { - index: -1 - }; - - chrome.tabs.move(tabIds, moveProperties); - }); -} - -// DEPRECATED in favor of closeBlankTabs, which is a superset of this -function closeOrphanedWindows() { - // close all windows whose only open tab is "chrome://newtab/" - // https://developer.chrome.com/extensions/windows#method-remove - const getInfo = { - populate: true, - windowTypes: ['normal'] - }; - const callback = function (windows) { - windows.forEach((window) => { - if (window.tabs.length === 1) { - if (window.tabs[0].url === NEW_TAB_URL) { - chrome.windows.remove(window.id); - } - } - }); - }; - chrome.windows.getAll(getInfo, callback); -} - -function repinTabs() { - // re-pin moved tabs that were previously pinned - // https://developer.chrome.com/docs/extensions/reference/tabs/#method-update - return Promise.all( - PINNED_TABS.map((tab) => { - return chrome.tabs.update(tab.id, { pinned: true }); - }) - ); -} - -function closeDomainTabs() { - // https://developer.chrome.com/docs/extensions/reference/tabs/#get-the-current-tab - - const queryOptions = { active: true, currentWindow: true }; - return chrome.tabs.query(queryOptions, function (tabs) { - const tab = tabs[0]; - const match = HOST_REGEX.exec(tab.url); - if (match) { - const host = match.groups.host; - closeTabsWithHostname(host); - } - }); -} - -function closeBlankTabs() { - // https://developer.chrome.com/extensions/tabs#method-query - const queryInfo = { - url: NEW_TAB_URL - }; - return chrome.tabs.query(queryInfo).then(function (tabs) { - const tabIds = tabs.map((tab) => { - return tab.id; - }); - return chrome.tabs.remove(tabIds); - }); -} - -// ----- HELPER FUNCTIONS -------------------- - -function closeTabsWithHostname(host) { - // https://developer.chrome.com/docs/extensions/reference/tabs/#method-query - // https://developer.chrome.com/docs/extensions/mv3/match_patterns/ - const urlPattern = '*://' + host + '/*'; - chrome.tabs.query({ url: urlPattern }, function (tabs) { - return Promise.all( - tabs.map((tab) => { - return chrome.tabs.remove(tab.id); - }) - ); - }); -} - -// ----- EVENT HANDLERS -------------------- - -function handleCollateTabsClicked() { - resetVariables(); - - // https://developer.chrome.com/extensions/windows#method-getAll - const getInfo = { - populate: true, - windowTypes: ['normal'] - }; - const callback = getWindowsCallbackFactory({ collate: true }); - chrome.windows.getAll(getInfo, callback); -} - -function handleConsolidateAllTabsClicked() { - resetVariables(); - - // https://developer.chrome.com/extensions/windows#method-getAll - const getInfo = { - populate: true, - windowTypes: ['normal'] - }; - const callback = getWindowsCallbackFactory({ - consolidate: true, - allTabs: true - }); - chrome.windows.getAll(getInfo, callback); -} - -function handleConsolidatePinnedTabsClicked() { - resetVariables(); - - // https://developer.chrome.com/extensions/windows#method-getAll - const getInfo = { - populate: true, - windowTypes: ['normal'] - }; - const callback = getWindowsCallbackFactory({ consolidate: true }); - chrome.windows.getAll(getInfo, callback); -} - -function handleDeduplicateTabsClicked() { - deduplicateTabs(); -} - -function handleSortWindowTabsClicked() { - sortWindowTabs(); -} - -function handleCloseOrphansClicked() { - closeOrphanedWindows(); -} - -function handleCloseDomainTabsClicked() { - closeDomainTabs(); -} - -function handleCloseBlankTabsClicked() { - closeBlankTabs(); -} - -function handleFocusAllWindowsClicked() { - // TODO: unused? - - // Move all windows in session to the front - // - // https://developer.chrome.com/extensions/windows#method-getAll - // https://developer.chrome.com/docs/extensions/reference/windows/#method-update - const getInfo = { - populate: false, - windowTypes: ['normal', 'popup'] - }; - - chrome.windows.getAll(getInfo, function (windows) { - const leftOffset = 40; - const topOffset = 40; - const count = 1; - windows.forEach((window) => { - chrome.windows.update(window.id, { - focused: true, - left: leftOffset * count, - top: topOffset * count - }); - ++count; - }); - }); -} - -// ----- COLLATE / CONSOLIDATE TAB HELPERS -------------------- - -function getWindowsCallbackFactory(cfg) { - if ( - typeof cfg.collate === 'undefined' && - typeof cfg.consolidate === 'undefined' - ) { - throw "getWindowsCallbackFactory must be invoked in 'collate' or 'consolidate' mode"; - } - - const callback = function (windows) { - const regularWindows = windows.filter((window) => { - return !window.incognito; - }); - - // set variables - NUM_WINDOWS = regularWindows.length; - NUM_WINDOWS_WITH_RESOLVED_TABS = 0; - - regularWindows.forEach((window) => { - // get all tabs in order to collate them - // https://developer.chrome.com/extensions/tabs#method-query - const queryInfo = { - windowId: window.id - }; - const callback = makeHandleGotTabsForWindow(window, cfg); - chrome.tabs.query(queryInfo, callback); - }); - }; - - return callback; -} - -function makeHandleGotTabsForWindow(window, cfg) { - const handler = function (tabs) { - ++NUM_WINDOWS_WITH_RESOLVED_TABS; - - const pinnedTabs = tabs.filter((tab) => { - return tab.pinned; - }); - - const unpinnedTabs = tabs.filter((tab) => { - return !tab.pinned; - }); - - if (SKIP_WINDOWS_WITH_PINNED_TABS && pinnedTabs.length > 0) { - // skip windows with pinned tabs - return false; - } else { - // store tabs for further processing - if (cfg.allTabs) { - PINNED_TABS = PINNED_TABS.concat(pinnedTabs); - } - WINDOW_TABS[window.id] = unpinnedTabs; - } - - if (NUM_WINDOWS_WITH_RESOLVED_TABS === NUM_WINDOWS) { - // got all the windows - if (cfg.collate) { - collateTabs(); - } else if (cfg.consolidate) { - consolidateTabs(); - } else { - // do nothing - } - } - }; - return handler; -} - -// ----- INITIALIZATION -------------------- - -function initEventHandlers() { - chrome.contextMenus.onClicked.addListener((info, tab) => { - // console.log(info); - // console.log(tab); - const callback = ACTION_CALLBACKS[info.menuItemId]; - if (callback) { - callback(); - } else { - console.log(`No action callback for ${menuItemId}.`); - } - }); -} - -function init() { - chrome.runtime.onInstalled.addListener(function () { - console.log('Organize Tabs Extension is installed'); - const menuId = 'organizeTabsContextMenu'; - - chrome.contextMenus.create({ - id: menuId, - title: 'Organize Tabs', - contexts: ['all'] - }); - - // sub-menus - ACTIONS.forEach((action) => { - chrome.contextMenus.create({ - id: action.id, - parentId: menuId, - title: action.name, - contexts: ['all'] - }); - }); - }); -} - -initEventHandlers(); -init(); diff --git a/src/js/popup.js b/src/js/popup.js deleted file mode 100644 index ef5de2f..0000000 --- a/src/js/popup.js +++ /dev/null @@ -1,7 +0,0 @@ -ACTIONS.forEach((action) => { - document.querySelectorAll(`.${action.cls}`).forEach((elt) => { - elt.addEventListener('click', (event) => { - action.callback(); - }); - }); -}); diff --git a/src/lib/actions.js b/src/lib/actions.js new file mode 100644 index 0000000..3784c3d --- /dev/null +++ b/src/lib/actions.js @@ -0,0 +1,1024 @@ +// Actions that touch the Chrome APIs. Every action has: +// id, name, group, help for menus and inline help +// preview(args) -> { items, title } optional, for actions that close tabs +// run(args) -> { message, closed } +// All actions run inside the service worker so they survive the popup closing. + +import { + colorForHost, + compareTabs, + compileMediaPatterns, + compileRules, + findDuplicates, + groupTitleForHost, + hostOf, + isBlankUrl, + isMediaTab, + isMediaUrl, + isStale, + isWebUrl, + scopesFor +} from './url.js'; +import { effectiveRules, loadSettings } from './settings.js'; + +const NO_GROUP = -1; +const UNDO_LIMIT = 10; + +// ----- CONTEXT -------------------- + +export async function loadContext() { + const loaded = await loadSettings(); + const { compiled, errors } = compileRules( + effectiveRules(loaded.rules, loaded.remoteRules) + ); + const media = compileMediaPatterns(loaded.settings.mediaPatterns); + return { ...loaded, compiled, ruleErrors: errors, media }; +} + +// ----- MEDIA / MEETINGS -------------------- + +function mediaTabsIn(win, ctx) { + return win.tabs.filter((t) => isMediaTab(t, ctx.media)); +} + +// Windows holding a meeting or playing media are "primary": consolidate and +// split leave them alone when the setting is on. +function withoutProtectedWindows(windows, ctx) { + if (!ctx.settings.protectMediaWindows) { + return { windows, protected: [] }; + } + const protectedWins = windows.filter((w) => mediaTabsIn(w, ctx).length > 0); + return { + windows: windows.filter((w) => !protectedWins.includes(w)), + protected: protectedWins + }; +} + +// Tabs can only move between windows of type "normal". A meeting popped out +// into its own window (type "popup") or an installed web app (type "app") +// cannot be moved, so those are reported rather than attempted. +async function movableMediaTabs(ctx) { + const all = await chrome.windows.getAll({ populate: true }); + const movable = []; + let stuck = 0; + for (const w of all) { + for (const t of mediaTabsIn(w, ctx)) { + if (w.type === 'normal' && !w.incognito) { + movable.push(t); + } else { + stuck += 1; + } + } + } + return { media: movable, stuck }; +} + +async function pullMediaTabs(ctx) { + const windows = await normalWindows(); + const { media, stuck } = await movableMediaTabs(ctx); + const stuckNote = stuck > 0 ? ` (${plural(stuck, 'tab')} in popup or app windows left as is)` : ''; + if (media.length === 0) { + return { + message: stuck > 0 + ? `Media is already in ${plural(stuck, 'separate popup or app window')}` + : 'No meeting or playing tabs found' + }; + } + // reuse a window that already holds only media tabs + let target = windows.find( + (w) => + w.tabs.some((t) => isMediaTab(t, ctx.media)) && + w.tabs.every((t) => isMediaTab(t, ctx.media) || isBlankUrl(t.url)) + ); + const toMove = media.filter((t) => !target || t.windowId !== target.id); + let created = false; + if (!target) { + target = await chrome.windows.create({ focused: true }); + created = true; + } + const pinned = toMove.filter((t) => t.pinned); + const unpinned = toMove.filter((t) => !t.pinned); + let pinnedIndex = target.tabs ? target.tabs.filter((t) => t.pinned).length : 0; + let failed = 0; + for (const t of pinned) { + try { + await chrome.tabs.move(t.id, { windowId: target.id, index: pinnedIndex }); + await chrome.tabs.update(t.id, { pinned: true }); + pinnedIndex += 1; + } catch (e) { + console.warn('could not move pinned media tab', t.url, e); + failed += 1; + } + } + if (unpinned.length > 0) { + try { + await moveTabsPreservingGroups(unpinned, target.id); + } catch (e) { + console.warn('bulk media move failed, retrying one by one', e); + for (const t of unpinned) { + try { + await chrome.tabs.move(t.id, { windowId: target.id, index: -1 }); + } catch (e2) { + failed += 1; + } + } + } + } + await closeBlankTabsIn(target.id); + // bring the meeting (or the first media tab) to the front + const focus = + media.find((t) => isMediaUrl(t.url, ctx.media) && t.audible) || + media.find((t) => isMediaUrl(t.url, ctx.media)) || + media[0]; + try { + await chrome.tabs.update(focus.id, { active: true }); + await chrome.windows.update(target.id, { focused: true }); + } catch (e) { + // the focused tab may have refused to move; the window is still up + } + const failedNote = failed > 0 ? `, ${failed} could not be moved` : ''; + return { + message: `${created ? 'Opened a window with' : 'Gathered'} ${plural(media.length - failed, 'media tab')}${failedNote}${stuckNote}` + }; +} + +// ----- TAB / WINDOW HELPERS -------------------- + +async function normalWindows({ includeIncognito = false } = {}) { + const windows = await chrome.windows.getAll({ + populate: true, + windowTypes: ['normal'] + }); + return windows.filter((w) => includeIncognito || !w.incognito); +} + +async function currentWindow() { + try { + return await chrome.windows.getLastFocused({ + populate: true, + windowTypes: ['normal'] + }); + } catch (e) { + const all = await normalWindows({ includeIncognito: true }); + return all[0] || null; + } +} + +async function activeTab(windowId) { + const tabs = await chrome.tabs.query( + windowId ? { active: true, windowId } : { active: true, lastFocusedWindow: true } + ); + return tabs[0] || null; +} + +function tabItem(tab) { + return { + id: tab.id, + title: tab.title || tab.url || '', + url: tab.url || tab.pendingUrl || '', + favIconUrl: tab.favIconUrl || '', + windowId: tab.windowId, + pinned: !!tab.pinned + }; +} + +function plural(n, word) { + return `${n} ${word}${n === 1 ? '' : 's'}`; +} + +// ----- TABS WE CREATE -------------------- + +// Tabs opened by Undo or Restore must not be re-closed by auto-dedupe. +const ownTabs = new Set(); + +export function markOwnTab(tabId) { + ownTabs.add(tabId); +} + +export function isOwnTab(tabId) { + return ownTabs.has(tabId); +} + +export function forgetOwnTab(tabId) { + ownTabs.delete(tabId); +} + +async function createOwnTab(props) { + const tab = await chrome.tabs.create(props); + markOwnTab(tab.id); + return tab; +} + +// ----- UNDO -------------------- + +async function readUndo() { + try { + const data = await chrome.storage.session.get('undo'); + return Array.isArray(data.undo) ? data.undo : []; + } catch (e) { + return []; + } +} + +async function pushUndo(entry) { + const stack = await readUndo(); + stack.push(entry); + while (stack.length > UNDO_LIMIT) { + stack.shift(); + } + try { + await chrome.storage.session.set({ undo: stack }); + } catch (e) { + console.warn('could not record undo entry', e); + } +} + +// Closes tabs and records what was closed so it can be reopened. +export async function closeTabs(tabs, label) { + const closable = tabs.filter((t) => t && typeof t.id === 'number'); + if (closable.length === 0) { + return 0; + } + await pushUndo({ + label, + at: Date.now(), + tabs: closable + .filter((t) => (t.url || t.pendingUrl) && !isBlankUrl(t.url)) + .map((t) => ({ + url: t.url || t.pendingUrl, + windowId: t.windowId, + pinned: !!t.pinned, + incognito: !!t.incognito + })) + }); + await chrome.tabs.remove(closable.map((t) => t.id)); + return closable.length; +} + +export async function undoLastClose() { + const stack = await readUndo(); + const entry = stack.pop(); + if (!entry) { + return { message: 'Nothing to undo' }; + } + await chrome.storage.session.set({ undo: stack }); + const windows = await normalWindows({ includeIncognito: true }); + const known = new Map(windows.map((w) => [w.id, w])); + const fallback = await currentWindow(); + let incognitoFallback = null; + let reopened = 0; + for (const t of entry.tabs) { + try { + const original = known.get(t.windowId); + let windowId; + if (original && original.incognito === !!t.incognito) { + windowId = original.id; + } else if (t.incognito) { + // never reopen a private URL in a normal window + if (!incognitoFallback) { + const w = await chrome.windows.create({ url: t.url, incognito: true, focused: false }); + incognitoFallback = w; + if (w.tabs && w.tabs[0]) { + markOwnTab(w.tabs[0].id); + } + reopened += 1; + continue; + } + windowId = incognitoFallback.id; + } else { + windowId = fallback?.id; + } + await createOwnTab({ url: t.url, windowId, pinned: t.pinned, active: false }); + reopened += 1; + } catch (e) { + console.warn('could not reopen', t.url, e); + } + } + return { + message: `Reopened ${plural(reopened, 'tab')} (${entry.label})`, + reopened + }; +} + +export async function undoAvailable() { + const stack = await readUndo(); + const last = stack[stack.length - 1]; + return last ? { label: last.label, count: last.tabs.length, at: last.at } : null; +} + +// ----- SORT (pinned-safe, group-safe) -------------------- + +// Final order: pinned tabs untouched, then tab groups sorted by title with +// their members sorted by URL, then ungrouped tabs sorted by URL. +export async function sortWindow(windowId) { + const tabs = await chrome.tabs.query({ windowId }); + const pinnedCount = tabs.filter((t) => t.pinned).length; + const unpinned = tabs.filter((t) => !t.pinned); + + const ungrouped = unpinned + .filter((t) => t.groupId === NO_GROUP) + .sort(compareTabs); + for (const t of ungrouped) { + await chrome.tabs.move(t.id, { index: -1 }); + } + + const groupIds = [...new Set(unpinned.map((t) => t.groupId))].filter( + (id) => id !== NO_GROUP + ); + const groups = []; + for (const id of groupIds) { + try { + groups.push(await chrome.tabGroups.get(id)); + } catch (e) { + // group vanished mid-sort + } + } + groups.sort((a, b) => { + const ta = (a.title || '').toLowerCase(); + const tb = (b.title || '').toLowerCase(); + return ta < tb ? -1 : ta > tb ? 1 : a.id - b.id; + }); + + let index = pinnedCount; + for (const g of groups) { + const members = unpinned + .filter((t) => t.groupId === g.id) + .sort(compareTabs); + await chrome.tabGroups.move(g.id, { index }); + for (let j = 0; j < members.length; j++) { + await chrome.tabs.move(members[j].id, { index: index + j }); + } + // moving can drop a tab out of its group; put them back + await chrome.tabs.group({ groupId: g.id, tabIds: members.map((m) => m.id) }); + index += members.length; + } + return { sorted: unpinned.length, groups: groups.length, pinned: pinnedCount }; +} + +// ----- GROUPING -------------------- + +async function groupWindowByDomain(win, ctx) { + const { minGroupSize, collapseGroups } = ctx.settings; + const candidates = win.tabs.filter( + (t) => !t.pinned && t.groupId === NO_GROUP && isWebUrl(t.url) + ); + const buckets = new Map(); + for (const t of candidates) { + const host = hostOf(t.url); + if (!buckets.has(host)) { + buckets.set(host, []); + } + buckets.get(host).push(t); + } + let created = 0; + let grouped = 0; + for (const [host, tabs] of buckets) { + if (tabs.length < Math.max(1, minGroupSize)) { + continue; + } + const title = groupTitleForHost(host); + const tabIds = tabs.sort(compareTabs).map((t) => t.id); + const existing = await chrome.tabGroups.query({ windowId: win.id, title }); + if (existing.length > 0) { + await chrome.tabs.group({ groupId: existing[0].id, tabIds }); + } else { + const groupId = await chrome.tabs.group({ + tabIds, + createProperties: { windowId: win.id } + }); + await chrome.tabGroups.update(groupId, { + title, + color: colorForHost(host), + collapsed: !!collapseGroups + }); + created += 1; + } + grouped += tabIds.length; + } + return { created, grouped }; +} + +// ----- MOVING TABS BETWEEN WINDOWS -------------------- + +// Chrome drops a tab out of its group when the tab alone changes window. +// Moving the whole group with tabGroups.move keeps it intact, and when the +// destination already has a group with the same name the tabs join that one. +async function findEquivalentGroup(windowId, title) { + if (!title) { + return null; + } + const groups = await chrome.tabGroups.query({ windowId }); + return groups.find((g) => (g.title || '').toLowerCase() === title.toLowerCase()) || null; +} + +async function moveTabsPreservingGroups(tabs, targetWindowId) { + const ungrouped = tabs.filter((t) => t.groupId === NO_GROUP); + if (ungrouped.length > 0) { + await chrome.tabs.move( + ungrouped.map((t) => t.id), + { windowId: targetWindowId, index: -1 } + ); + } + const groupIds = [...new Set(tabs.map((t) => t.groupId))].filter((id) => id !== NO_GROUP); + let merged = 0; + let moved = 0; + for (const groupId of groupIds) { + const members = tabs.filter((t) => t.groupId === groupId); + const tabIds = members.map((t) => t.id); + let info = null; + try { + info = await chrome.tabGroups.get(groupId); + } catch (e) { + // group already gone; fall through and move the tabs plainly + } + const equivalent = info ? await findEquivalentGroup(targetWindowId, info.title) : null; + if (equivalent) { + await chrome.tabs.move(tabIds, { windowId: targetWindowId, index: -1 }); + await chrome.tabs.group({ groupId: equivalent.id, tabIds }); + merged += 1; + continue; + } + // only carry the group over whole when every member is being moved; + // otherwise the tabs staying behind would be dragged along + let keptWhole = false; + const wholeGroup = info + ? (await chrome.tabs.query({ groupId })).length === members.length + : false; + if (info && wholeGroup) { + try { + await chrome.tabGroups.move(groupId, { windowId: targetWindowId, index: -1 }); + keptWhole = true; + } catch (e) { + // older Chrome cannot move a group across windows; rebuild it + } + } + if (!keptWhole) { + await chrome.tabs.move(tabIds, { windowId: targetWindowId, index: -1 }); + const newId = await chrome.tabs.group({ + tabIds, + createProperties: { windowId: targetWindowId } + }); + if (info) { + await chrome.tabGroups.update(newId, { + title: info.title || '', + color: info.color, + collapsed: !!info.collapsed + }); + } + } + moved += 1; + } + return { tabs: tabs.length, groupsMoved: moved, groupsMerged: merged }; +} + +// ----- CONSOLIDATE / SPLIT -------------------- + +async function consolidate({ includePinned }) { + const ctx = await loadContext(); + const all = await normalWindows(); + const { windows, protected: protectedWins } = withoutProtectedWindows(all, ctx); + if (windows.length < 2) { + return { + message: + protectedWins.length > 0 + ? `Nothing to consolidate outside ${plural(protectedWins.length, 'window')} with a meeting or media` + : 'Only one window open, nothing to consolidate' + }; + } + const focused = windows.find((w) => w.focused); + const target = + focused || windows.slice().sort((a, b) => b.tabs.length - a.tabs.length)[0]; + let moved = 0; + let groupsKept = 0; + let groupsMerged = 0; + let pinnedIndex = target.tabs.filter((t) => t.pinned).length; + + for (const w of windows) { + if (w.id === target.id) { + continue; + } + const pinned = w.tabs.filter((t) => t.pinned); + const unpinned = w.tabs.filter((t) => !t.pinned); + if (includePinned) { + for (const t of pinned) { + // Chromium unpins a tab when it changes window; move then re-pin + await chrome.tabs.move(t.id, { windowId: target.id, index: pinnedIndex }); + await chrome.tabs.update(t.id, { pinned: true }); + pinnedIndex += 1; + moved += 1; + } + } + if (unpinned.length > 0) { + const r = await moveTabsPreservingGroups(unpinned, target.id); + moved += r.tabs; + groupsKept += r.groupsMoved; + groupsMerged += r.groupsMerged; + } + } + await sortWindow(target.id); + await closeBlankTabsIn(target.id); + const groupNote = + groupsKept + groupsMerged > 0 + ? `, kept ${plural(groupsKept, 'group')}${groupsMerged ? ` and merged ${groupsMerged}` : ''}` + : ''; + const mediaNote = protectedWins.length > 0 ? `; left ${plural(protectedWins.length, 'media window')} alone` : ''; + return { + message: `Moved ${plural(moved, 'tab')} into one window and sorted it${groupNote}${mediaNote}` + }; +} + +async function splitWindowsByDomain() { + const ctx = await loadContext(); + const { windows, protected: protectedWins } = withoutProtectedWindows(await normalWindows(), ctx); + const tabs = windows + .flatMap((w) => w.tabs) + .filter((t) => !t.pinned && isWebUrl(t.url)); + const buckets = new Map(); + for (const t of tabs) { + const host = hostOf(t.url); + if (!buckets.has(host)) { + buckets.set(host, []); + } + buckets.get(host).push(t); + } + const multi = [...buckets.entries()] + .filter(([, list]) => list.length > 1) + .sort(([a], [b]) => (a < b ? -1 : 1)); + const singles = [...buckets.values()] + .filter((list) => list.length === 1) + .map((list) => list[0]) + .sort(compareTabs); + + // Sequential, one window at a time, so our own blank-tab cleanup cannot + // race a window whose tabs have not arrived yet. Group membership travels + // with the tabs. + async function newWindowWith(list) { + const win = await chrome.windows.create({ focused: false }); + await moveTabsPreservingGroups(list, win.id); + await closeBlankTabsIn(win.id); + return win; + } + + let created = 0; + for (const [, list] of multi) { + await newWindowWith(list.sort(compareTabs)); + created += 1; + } + if (singles.length > 0) { + await newWindowWith(singles); + created += 1; + } + const mediaNote = protectedWins.length > 0 ? `; left ${plural(protectedWins.length, 'media window')} alone` : ''; + return { message: `Split ${plural(tabs.length, 'tab')} into ${plural(created, 'window')}${mediaNote}` }; +} + +// ----- CLOSERS -------------------- + +async function closeBlankTabsIn(windowId) { + const tabs = await chrome.tabs.query({ windowId }); + const blank = tabs.filter((t) => isBlankUrl(t.url) && !t.pinned); + if (blank.length > 0 && blank.length < tabs.length) { + await chrome.tabs.remove(blank.map((t) => t.id)); + } + return blank.length; +} + +async function closeBlankTabsEverywhere() { + const windows = await normalWindows({ includeIncognito: true }); + let closed = 0; + for (const w of windows) { + const blank = w.tabs.filter((t) => isBlankUrl(t.url) && !t.pinned); + if (blank.length === 0) { + continue; + } + // keep one blank tab in a window that has nothing else, so the + // window itself survives + const ids = blank.length === w.tabs.length ? blank.slice(1) : blank; + if (ids.length > 0) { + await chrome.tabs.remove(ids.map((t) => t.id)); + closed += ids.length; + } + } + return closed; +} + +async function duplicateItems(ctx) { + const tabs = await chrome.tabs.query({}); + const sets = findDuplicates( + tabs, + ctx.compiled, + ctx.settings.normalization, + ctx.settings.survivorPolicy + ); + return { sets, tabs: sets.flatMap((s) => s.close) }; +} + +async function scopeTabs(ctx, args) { + const tab = args.tabId + ? await chrome.tabs.get(args.tabId) + : await activeTab(); + if (!tab) { + return { tab: null, scopes: [], tabs: [] }; + } + const url = tab.url || tab.pendingUrl || ''; + const all = await chrome.tabs.query({}); + const scopes = scopesFor(url, ctx.compiled).map((scope) => { + const matches = all.filter( + (t) => + t.incognito === tab.incognito && + !t.pinned && + scope.test(t.url || t.pendingUrl || '') + ); + return { ...scope, matches }; + }); + const chosen = scopes.find((s) => s.id === (args.scope || 'domain')) || scopes[0]; + const tabs = chosen + ? chosen.matches.filter((t) => !(args.keepCurrent && t.id === tab.id)) + : []; + return { tab, scopes, chosen, tabs }; +} + +async function staleTabs(ctx, args) { + const days = args.days ?? ctx.settings.staleDays; + const all = await chrome.tabs.query({}); + return all.filter( + (t) => + !t.pinned && + !t.active && + !t.audible && + !isBlankUrl(t.url) && + isStale(t, days) + ); +} + +// ----- BOOKMARK PARKING -------------------- + +async function parkFolder(ctx, create) { + const name = ctx.settings.parkFolderName || 'Organize Tabs'; + const found = (await chrome.bookmarks.search({ title: name })).filter((b) => !b.url); + if (found.length > 0) { + return found[0]; + } + return create ? chrome.bookmarks.create({ title: name }) : null; +} + +function stamp() { + const d = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad( + d.getHours() + )}:${pad(d.getMinutes())}`; +} + +async function parkableTabs(args) { + const win = args.windowId + ? await chrome.windows.get(args.windowId, { populate: true }) + : await currentWindow(); + if (!win) { + return { win: null, tabs: [] }; + } + return { win, tabs: win.tabs.filter((t) => !t.pinned && isWebUrl(t.url)) }; +} + +async function latestParkedFolder(ctx) { + const root = await parkFolder(ctx, false); + if (!root) { + return null; + } + const children = (await chrome.bookmarks.getChildren(root.id)).filter((b) => !b.url); + children.sort((a, b) => (b.dateAdded || 0) - (a.dateAdded || 0)); + if (!children[0]) { + return null; + } + const items = (await chrome.bookmarks.getChildren(children[0].id)).filter((b) => b.url); + return { folder: children[0], items }; +} + +// ----- ACTION REGISTRY -------------------- + +export const ACTIONS = [ + { + id: 'groupByDomain', + name: 'Group Tabs by Domain', + group: 'organize', + help: 'Puts tabs from the same site into a tab group, in every window. Pinned tabs and existing groups are left alone.', + async run() { + const ctx = await loadContext(); + const windows = await normalWindows({ includeIncognito: true }); + let created = 0; + let grouped = 0; + for (const w of windows) { + const r = await groupWindowByDomain(w, ctx); + created += r.created; + grouped += r.grouped; + } + return { + message: `Grouped ${plural(grouped, 'tab')} into ${plural(created, 'new group')}` + }; + } + }, + { + id: 'sortWindow', + name: 'Sort Tabs in Window', + group: 'organize', + help: 'Sorts the current window by site and URL. Pinned tabs stay put and tab groups stay together, sorted by name.', + async run() { + const win = await currentWindow(); + if (!win) { + return { message: 'No window to sort' }; + } + const r = await sortWindow(win.id); + return { message: `Sorted ${plural(r.sorted, 'tab')} across ${plural(r.groups, 'group')}` }; + } + }, + { + id: 'ungroupWindow', + name: 'Ungroup Tabs in Window', + group: 'organize', + help: 'Removes all tab groups in the current window without closing anything.', + async run() { + const win = await currentWindow(); + const ids = (win?.tabs || []).filter((t) => t.groupId !== NO_GROUP).map((t) => t.id); + if (ids.length > 0) { + await chrome.tabs.ungroup(ids); + } + return { message: `Ungrouped ${plural(ids.length, 'tab')}` }; + } + }, + { + id: 'consolidateAll', + name: 'Consolidate All Tabs', + group: 'organize', + help: 'Moves every tab, pinned ones included, into the current window and sorts it. Tab groups come along, and a group joins an existing group with the same name.', + run: () => consolidate({ includePinned: true }) + }, + { + id: 'consolidateUnpinned', + name: 'Consolidate Unpinned Tabs', + group: 'organize', + help: 'Moves unpinned tabs into the current window and sorts it. Pinned tabs stay in their windows; tab groups come along.', + run: () => consolidate({ includePinned: false }) + }, + { + id: 'splitByDomain', + name: 'Split Windows by Domain', + group: 'organize', + help: 'One window per site, plus one window for sites with a single tab. Pinned tabs stay where they are and tab groups are kept. Prefer "Group Tabs by Domain" for a lighter touch.', + run: splitWindowsByDomain + }, + { + id: 'dedupe', + name: 'Deduplicate Tabs', + group: 'cleanup', + help: 'Closes tabs that point at the same thing. Understands PR, ticket, profile and document pages via the rules in Settings, and keeps the pinned, active or most recent copy.', + async preview() { + const ctx = await loadContext(); + const { sets, tabs } = await duplicateItems(ctx); + return { + title: `${plural(tabs.length, 'duplicate')} across ${plural(sets.length, 'set')}`, + items: tabs.map(tabItem), + confirmLabel: `Close ${plural(tabs.length, 'tab')}` + }; + }, + async run() { + const ctx = await loadContext(); + const { tabs } = await duplicateItems(ctx); + const closed = await closeTabs(tabs, 'Deduplicate'); + return { message: `Closed ${plural(closed, 'duplicate')}`, closed }; + } + }, + { + id: 'closeScope', + name: 'Close Tabs Like This One', + group: 'cleanup', + help: 'Closes tabs from the same domain, the same section of the site, or the same kind of page (all pull requests, all profiles). Pinned tabs are never closed.', + async options(args = {}) { + const ctx = await loadContext(); + const { tab, scopes } = await scopeTabs(ctx, args); + return { + tab: tab ? tabItem(tab) : null, + scopes: scopes.map((s) => ({ id: s.id, label: s.label, count: s.matches.length })) + }; + }, + async preview(args = {}) { + const ctx = await loadContext(); + const { chosen, tabs } = await scopeTabs(ctx, args); + return { + title: chosen ? `${plural(tabs.length, 'tab')} from ${chosen.label}` : 'Nothing to close', + items: tabs.map(tabItem), + confirmLabel: `Close ${plural(tabs.length, 'tab')}` + }; + }, + async run(args = {}) { + const ctx = await loadContext(); + const { chosen, tabs } = await scopeTabs(ctx, args); + const closed = await closeTabs(tabs, `Close ${chosen?.label || 'tabs'}`); + return { message: `Closed ${plural(closed, 'tab')} from ${chosen?.label || 'scope'}`, closed }; + } + }, + { + id: 'closeStale', + name: 'Close Stale Tabs', + group: 'cleanup', + help: 'Closes tabs you have not looked at for a while (the number of days is in Settings). Pinned, active and playing tabs are skipped.', + async preview(args = {}) { + const ctx = await loadContext(); + const tabs = await staleTabs(ctx, args); + const days = args.days ?? ctx.settings.staleDays; + return { + title: `${plural(tabs.length, 'tab')} untouched for ${plural(days, 'day')}`, + items: tabs.map(tabItem), + confirmLabel: `Close ${plural(tabs.length, 'tab')}` + }; + }, + async run(args = {}) { + const ctx = await loadContext(); + const tabs = await staleTabs(ctx, args); + const closed = await closeTabs(tabs, 'Close stale'); + return { message: `Closed ${plural(closed, 'stale tab')}`, closed }; + } + }, + { + id: 'discardStale', + name: 'Free Memory (Discard Stale Tabs)', + group: 'cleanup', + help: 'Unloads stale tabs from memory without closing them. They reload when you click them.', + async run(args = {}) { + const ctx = await loadContext(); + const tabs = (await staleTabs(ctx, args)).filter((t) => !t.discarded); + let discarded = 0; + for (const t of tabs) { + try { + await chrome.tabs.discard(t.id); + discarded += 1; + } catch (e) { + // some tabs refuse to be discarded + } + } + return { message: `Discarded ${plural(discarded, 'tab')}` }; + } + }, + { + id: 'closeBlank', + name: 'Close Blank Tabs', + group: 'cleanup', + help: 'Closes empty new-tab pages everywhere, keeping one if a window would otherwise be empty.', + async run() { + const closed = await closeBlankTabsEverywhere(); + return { message: `Closed ${plural(closed, 'blank tab')}` }; + } + }, + { + id: 'parkWindow', + name: 'Park Window to Bookmarks', + group: 'cleanup', + help: 'Saves every unpinned tab in the current window into a dated bookmark folder, then closes them. Restore later with "Restore Last Parked".', + async preview(args = {}) { + const { tabs } = await parkableTabs(args); + return { + title: `Park ${plural(tabs.length, 'tab')} to bookmarks`, + items: tabs.map(tabItem), + confirmLabel: `Park ${plural(tabs.length, 'tab')}` + }; + }, + async run(args = {}) { + const ctx = await loadContext(); + const { win, tabs } = await parkableTabs(args); + if (tabs.length === 0) { + return { message: 'Nothing to park' }; + } + const root = await parkFolder(ctx, true); + const folder = await chrome.bookmarks.create({ + parentId: root.id, + title: `Parked ${stamp()} (${plural(tabs.length, 'tab')})` + }); + for (const t of tabs) { + await chrome.bookmarks.create({ + parentId: folder.id, + title: t.title || t.url, + url: t.url + }); + } + if (win && win.tabs.length === tabs.length) { + // closing every tab would close the window; keep it alive first + await chrome.tabs.create({ windowId: win.id }); + } + const closed = await closeTabs(tabs, 'Park window'); + return { message: `Parked ${plural(closed, 'tab')} into "${folder.title}"`, closed }; + } + }, + { + id: 'restoreParked', + name: 'Restore Last Parked', + group: 'cleanup', + help: 'Reopens the most recently parked folder as a tab group in the current window and removes the folder.', + async preview() { + const ctx = await loadContext(); + const parked = await latestParkedFolder(ctx); + return { + title: parked ? parked.folder.title : 'No parked folders', + items: (parked?.items || []).map((b) => ({ id: b.id, title: b.title, url: b.url })), + confirmLabel: `Restore ${plural(parked?.items.length || 0, 'tab')}` + }; + }, + async run() { + const ctx = await loadContext(); + const parked = await latestParkedFolder(ctx); + if (!parked || parked.items.length === 0) { + return { message: 'No parked folders to restore' }; + } + const win = await currentWindow(); + const ids = []; + for (const b of parked.items) { + const t = await createOwnTab({ windowId: win.id, url: b.url, active: false }); + ids.push(t.id); + } + const groupId = await chrome.tabs.group({ tabIds: ids, createProperties: { windowId: win.id } }); + await chrome.tabGroups.update(groupId, { title: parked.folder.title.replace(/^Parked /, '') }); + await chrome.bookmarks.removeTree(parked.folder.id); + return { message: `Restored ${plural(ids.length, 'tab')} from "${parked.folder.title}"` }; + } + }, + { + id: 'pullMedia', + name: 'Pull Meetings & Media to a Window', + group: 'windows', + help: 'Moves every tab that is playing audio or showing a meeting or video player into its own window and focuses the meeting. Consolidate and Split then leave that window alone.', + async run() { + const ctx = await loadContext(); + return pullMediaTabs(ctx); + } + }, + { + id: 'focusAllWindows', + name: 'Bring All Windows to Front', + group: 'windows', + help: 'Focuses every window in turn and cascades them so misplaced windows reappear.', + async run() { + const windows = await chrome.windows.getAll({ windowTypes: ['normal', 'popup'] }); + let count = 0; + let failed = 0; + for (const w of windows) { + count += 1; + try { + if (w.state === 'minimized') { + await chrome.windows.update(w.id, { state: 'normal' }); + } + // position and state cannot be set in the same call, and + // maximized or fullscreen windows refuse a position + const normal = w.state === 'normal' || w.state === 'minimized'; + await chrome.windows.update( + w.id, + normal ? { focused: true, left: 40 * count, top: 40 * count } : { focused: true } + ); + } catch (e) { + failed += 1; + } + } + return { + message: `Brought ${plural(count - failed, 'window')} to front${failed ? `, ${failed} refused` : ''}` + }; + } + }, + { + id: 'undo', + name: 'Undo Last Close', + group: 'windows', + help: 'Reopens the tabs closed by the last Organize Tabs action in this browser session.', + run: undoLastClose + } +]; + +export const ACTION_BY_ID = Object.fromEntries(ACTIONS.map((a) => [a.id, a])); + +// ----- STATS -------------------- + +export async function stats() { + const ctx = await loadContext(); + const tabs = await chrome.tabs.query({}); + const windows = await chrome.windows.getAll({ windowTypes: ['normal'] }); + const groups = await chrome.tabGroups.query({}); + const dupes = findDuplicates( + tabs, + ctx.compiled, + ctx.settings.normalization, + ctx.settings.survivorPolicy + ); + const undo = await undoAvailable(); + return { + tabs: tabs.length, + windows: windows.length, + groups: groups.length, + pinned: tabs.filter((t) => t.pinned).length, + duplicates: dupes.reduce((n, s) => n + s.close.length, 0), + stale: (await staleTabs(ctx, {})).length, + blank: tabs.filter((t) => isBlankUrl(t.url) && !t.pinned).length, + discarded: tabs.filter((t) => t.discarded).length, + media: tabs.filter((t) => isMediaTab(t, ctx.media)).length, + staleDays: ctx.settings.staleDays, + ruleErrors: ctx.ruleErrors.length, + undo + }; +} + +export { duplicateItems }; diff --git a/src/lib/settings.js b/src/lib/settings.js new file mode 100644 index 0000000..0eb7a4a --- /dev/null +++ b/src/lib/settings.js @@ -0,0 +1,220 @@ +// Settings persistence. Preferences and rules live in chrome.storage.sync so +// they follow the browser profile across machines; chrome.storage.local is the +// fallback when sync is unavailable and the home of caches (remote rules). + +import { DEFAULT_MEDIA_PATTERNS, DEFAULT_NORMALIZATION, DEFAULT_RULES } from './url.js'; + +export const SETTINGS_VERSION = 1; + +export const DEFAULT_SETTINGS = { + // which duplicate to keep: 'recent' (last accessed), 'first' (leftmost), 'deepest' (longest URL) + survivorPolicy: 'recent', + // close a freshly opened tab when its URL is already open elsewhere + autoDedupe: false, + // show a preview before closing tabs from the popup + confirmClose: true, + // show the duplicate count on the toolbar icon + showBadge: true, + // "stale" means not accessed for this many days + staleDays: 7, + // only create a tab group when at least this many tabs share a host + minGroupSize: 2, + // collapse groups created by "Group by domain" + collapseGroups: false, + // bookmark folder used by "Park window" + parkFolderName: 'Organize Tabs', + normalization: { ...DEFAULT_NORMALIZATION }, + // leave windows that hold a meeting or playing media alone when + // consolidating or splitting + protectMediaWindows: true, + // URL patterns that count as a meeting or media player even when silent + mediaPatterns: [...DEFAULT_MEDIA_PATTERNS], + // optional URL of a JSON file with {"rules": [...]} to merge in + remoteRulesUrl: '', + remoteRefreshHours: 24 +}; + +const SYNC_KEYS = ['settings', 'rules']; + +function area(name) { + return chrome.storage && chrome.storage[name] ? chrome.storage[name] : null; +} + +async function readArea(name, keys) { + const store = area(name); + if (!store) { + return {}; + } + try { + return await store.get(keys); + } catch (e) { + console.warn(`storage.${name}.get failed`, e); + return {}; + } +} + +async function writeArea(name, data) { + const store = area(name); + if (!store) { + return false; + } + try { + await store.set(data); + return true; + } catch (e) { + console.warn(`storage.${name}.set failed`, e); + return false; + } +} + +export function mergeSettings(stored) { + const s = { ...DEFAULT_SETTINGS, ...(stored || {}) }; + s.normalization = { + ...DEFAULT_NORMALIZATION, + ...((stored && stored.normalization) || {}) + }; + return s; +} + +// Returns { settings, rules, remoteRules, source } where rules are the user's +// own rules (defaults on first run) and remoteRules is the cached remote list. +// Both areas are read and the more recently saved copy wins, so a save that +// fell back to local (sync quota) is not shadowed by stale sync data. +export async function loadSettings() { + const [sync, local] = await Promise.all([ + readArea('sync', [...SYNC_KEYS, 'savedAt']), + readArea('local', [...SYNC_KEYS, 'savedAt']) + ]); + const has = (d) => !!(d.settings || d.rules); + let data = {}; + let source = 'sync'; + if (has(sync) && has(local)) { + if ((local.savedAt || 0) > (sync.savedAt || 0)) { + data = local; + source = 'local'; + } else { + data = sync; + } + } else if (has(local)) { + data = local; + source = 'local'; + } else if (has(sync)) { + data = sync; + } + const cache = await readArea('local', ['remoteRules', 'remoteRulesMeta']); + return { + settings: mergeSettings(data.settings), + rules: Array.isArray(data.rules) ? data.rules : defaultRules(), + remoteRules: Array.isArray(cache.remoteRules) ? cache.remoteRules : [], + remoteRulesMeta: cache.remoteRulesMeta || null, + source + }; +} + +export function defaultRules() { + return DEFAULT_RULES.map((r) => ({ ...r, enabled: true, builtin: true })); +} + +export async function saveSettings({ settings, rules }) { + const payload = {}; + if (settings) { + payload.settings = mergeSettings(settings); + } + if (rules) { + payload.rules = rules; + } + payload.savedAt = Date.now(); + // sync has an 8KB per-item limit; fall back to local when it refuses + const ok = await writeArea('sync', payload); + if (!ok) { + await writeArea('local', payload); + return 'local'; + } + // keep local in step so a later fallback compares against fresh data + await writeArea('local', payload); + return 'sync'; +} + +// Effective rule list: the user's rules first, then remote rules that do not +// share a name with a local one. +export function effectiveRules(rules, remoteRules) { + const names = new Set((rules || []).map((r) => r.name)); + const extra = (remoteRules || []).filter((r) => !names.has(r.name)); + return [...(rules || []), ...extra.map((r) => ({ ...r, remote: true }))]; +} + +export function exportBundle({ settings, rules }) { + return { + app: 'organize-tabs', + version: SETTINGS_VERSION, + exportedAt: new Date().toISOString(), + settings: mergeSettings(settings), + rules: rules || [] + }; +} + +// Accepts a full export bundle or a bare {rules: [...]} / [...] list. +export function parseImport(text) { + let data; + try { + data = JSON.parse(text); + } catch (e) { + throw new Error(`Not valid JSON: ${e.message}`); + } + if (Array.isArray(data)) { + data = { rules: data }; + } + if (!data || typeof data !== 'object') { + throw new Error('Expected a JSON object'); + } + const out = {}; + if (data.settings && typeof data.settings === 'object') { + out.settings = mergeSettings(data.settings); + } + if (data.rules !== undefined) { + if (!Array.isArray(data.rules)) { + throw new Error('"rules" must be an array'); + } + data.rules.forEach((r, i) => { + if (!r || typeof r.match !== 'string' || typeof r.key !== 'string') { + throw new Error(`Rule ${i + 1} needs string "match" and "key"`); + } + }); + out.rules = data.rules.map((r) => ({ + name: r.name || `Rule`, + match: r.match, + key: r.key, + enabled: r.enabled !== false + })); + } + if (!out.settings && !out.rules) { + throw new Error('Nothing to import: no "settings" or "rules" found'); + } + return out; +} + +export async function fetchRemoteRules(url) { + const res = await fetch(url, { cache: 'no-store' }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const parsed = parseImport(await res.text()); + const rules = parsed.rules || []; + const meta = { url, fetchedAt: Date.now(), count: rules.length, error: null }; + await writeArea('local', { remoteRules: rules, remoteRulesMeta: meta }); + return meta; +} + +export async function recordRemoteError(url, error) { + const cache = await readArea('local', ['remoteRulesMeta']); + const meta = { ...(cache.remoteRulesMeta || {}), url, error: String(error) }; + await writeArea('local', { remoteRulesMeta: meta }); + return meta; +} + +export async function clearRemoteRules() { + const store = area('local'); + if (store) { + await store.remove(['remoteRules', 'remoteRulesMeta']); + } +} diff --git a/src/lib/url.js b/src/lib/url.js new file mode 100644 index 0000000..2bdba6f --- /dev/null +++ b/src/lib/url.js @@ -0,0 +1,491 @@ +// Pure URL logic: normalization, entity rules, duplicate detection, sorting. +// No Chrome APIs here so it can be unit tested with `node --test`. + +export const TRACKING_PARAMS = [ + 'utm_source', + 'utm_medium', + 'utm_campaign', + 'utm_term', + 'utm_content', + 'utm_id', + 'fbclid', + 'gclid', + 'dclid', + 'msclkid', + 'mc_cid', + 'mc_eid', + 'igshid', + 'ref', + 'ref_src', + 'ref_url', + 'si', + '_hsenc', + '_hsmi', + 'hsCtaTracking', + 'vero_id', + 'yclid', + 'trk', + 'trkInfo', + 'ocid', + 's', + 'feature' +]; + +// Built-in entity rules. `match` is a regex source tested against the raw URL, +// `key` is a template where $1..$9 refer to capture groups. Two tabs whose URLs +// produce the same key are considered duplicates even if the URLs differ. +export const DEFAULT_RULES = [ + { + name: 'GitHub pull request', + match: '^https?://github\\.com/([^/]+)/([^/]+)/pull/(\\d+)', + key: 'github:$1/$2/pull/$3' + }, + { + name: 'GitHub issue', + match: '^https?://github\\.com/([^/]+)/([^/]+)/issues/(\\d+)', + key: 'github:$1/$2/issues/$3' + }, + { + name: 'Bitbucket pull request', + match: '^https?://bitbucket\\.org/([^/]+)/([^/]+)/pull-requests/(\\d+)', + key: 'bitbucket:$1/$2/pr/$3' + }, + { + name: 'GitLab merge request', + match: '^https?://gitlab\\.com/(.+?)/-/merge_requests/(\\d+)', + key: 'gitlab:$1/mr/$2' + }, + { + name: 'Jira issue', + match: '^https?://([^/]+\\.atlassian\\.net)/(?:browse/|.*[?&]selectedIssue=)([A-Z][A-Z0-9]+-\\d+)', + key: 'jira:$1/$2' + }, + { + name: 'Confluence page', + match: '^https?://([^/]+\\.atlassian\\.net)/wiki/spaces/[^/]+/pages/(\\d+)', + key: 'confluence:$1/$2' + }, + { + name: 'LinkedIn profile', + match: '^https?://(?:[a-z]+\\.)?linkedin\\.com/in/([^/?#]+)', + key: 'linkedin:in/$1' + }, + { + name: 'LinkedIn company', + match: '^https?://(?:[a-z]+\\.)?linkedin\\.com/company/([^/?#]+)', + key: 'linkedin:company/$1' + }, + { + name: 'YouTube video', + match: '^https?://(?:(?:www|m)\\.youtube\\.com/watch\\?(?:.*&)?v=|youtu\\.be/)([\\w-]{6,})', + key: 'youtube:$1' + }, + { + name: 'Google Docs / Sheets / Slides', + match: '^https?://docs\\.google\\.com/(document|spreadsheets|presentation|forms)/d/([^/?#]+)', + key: 'gdocs:$1/$2' + }, + { + name: 'Google Drive file', + match: '^https?://drive\\.google\\.com/(?:file/d/|drive/folders/)([^/?#]+)', + key: 'gdrive:$1' + }, + { + name: 'Notion page', + match: '^https?://(?:www\\.)?(?:[^/]+\\.)?notion\\.(?:so|site|com)/(?:[^/?#]+/)*?[^/?#]*?([0-9a-f]{32})(?:[?#/]|$)', + key: 'notion:$1' + }, + { + name: 'HubSpot record', + match: '^https?://app(?:-[a-z0-9]+)?\\.hubspot\\.com/contacts/(\\d+)/(?:record/)?([\\w-]+)/(\\d+)', + key: 'hubspot:$1/$2/$3' + }, + { + name: 'Slack channel or thread', + match: '^https?://app\\.slack\\.com/client/([A-Z0-9]+)/([A-Z0-9]+)(?:/thread/[A-Z0-9]+-(\\d+\\.\\d+))?', + key: 'slack:$1/$2/$3' + }, + { + name: 'Figma file', + match: '^https?://(?:www\\.)?figma\\.com/(?:file|design|board|proto)/([\\w-]+)', + key: 'figma:$1' + }, + { + name: 'Amazon product', + match: '^https?://(?:www\\.)?amazon\\.[a-z.]+/(?:.*/)?(?:dp|gp/product)/([A-Z0-9]{10})', + key: 'amazon:$1' + }, + { + name: 'Stack Overflow question', + match: '^https?://stackoverflow\\.com/(?:questions|q)/(\\d+)', + key: 'stackoverflow:$1' + }, + { + name: 'Reddit post', + match: '^https?://(?:www|old|new)\\.reddit\\.com/r/([^/]+)/comments/([a-z0-9]+)', + key: 'reddit:$1/$2' + }, + { + name: 'Twitter / X post', + match: '^https?://(?:www\\.)?(?:twitter|x)\\.com/[^/]+/status/(\\d+)', + key: 'x:$1' + } +]; + +// Pages that are a meeting or a media player even when silent (muted call, +// paused video). Audible tabs count as media regardless of URL. +export const DEFAULT_MEDIA_PATTERNS = [ + '^https?://meet\\.google\\.com/[a-z]{3}-[a-z]{4}-[a-z]{3}', + '^https?://[^/]*zoom\\.us/(j|wc|s|my)/', + '^https?://teams\\.(microsoft|live)\\.com/.*(meetup-join|/meet/|/v2/)', + '^https?://[^/]*whereby\\.com/', + '^https?://[^/]*around\\.co/', + '^https?://[^/]*webex\\.com/(meet|join|wbxmjs)', + '^https?://app\\.gather\\.town/', + '^https?://discord\\.com/channels/', + '^https?://app\\.slack\\.com/huddle/', + '^https?://(www|m)\\.youtube\\.com/watch', + '^https?://music\\.youtube\\.com/', + '^https?://[^/]*twitch\\.tv/', + '^https?://[^/]*vimeo\\.com/\\d+', + '^https?://open\\.spotify\\.com/', + '^https?://[^/]*netflix\\.com/watch/', + '^https?://[^/]*loom\\.com/share/' +]; + +export function compileMediaPatterns(patterns) { + const compiled = []; + for (const p of patterns || []) { + try { + compiled.push(new RegExp(p, 'i')); + } catch (e) { + // ignore bad patterns; the options page reports them + } + } + return compiled; +} + +export function isMediaUrl(url, compiledMedia = []) { + return !!url && compiledMedia.some((re) => re.test(url)); +} + +// A media tab is audible, or on a meeting/player page even while silent. +export function isMediaTab(tab, compiledMedia = []) { + return !!tab.audible || isMediaUrl(tab.url || tab.pendingUrl || '', compiledMedia); +} + +export const DEFAULT_NORMALIZATION = { + ignoreScheme: true, + stripWww: true, + ignoreFragment: true, + ignoreTrailingSlash: true, + stripTrackingParams: true, + sortQueryParams: true, + trackingParams: TRACKING_PARAMS +}; + +export const BLANK_URLS = new Set([ + 'chrome://newtab/', + 'chrome://new-tab-page/', + 'chrome://new-tab-page-third-party/', + 'brave://newtab/', + 'edge://newtab/', + 'about:blank', + 'about:newtab', + '' +]); + +export function isBlankUrl(url) { + return BLANK_URLS.has(url || ''); +} + +export function parseUrl(url) { + try { + return new URL(url); + } catch (e) { + return null; + } +} + +export function isWebUrl(url) { + const u = parseUrl(url); + return !!u && (u.protocol === 'http:' || u.protocol === 'https:'); +} + +// Hostname without a leading www., or null for non-web URLs. +export function hostOf(url) { + const u = parseUrl(url); + if (!u || !u.hostname) { + return null; + } + return u.hostname.toLowerCase().replace(/^www\./, ''); +} + +// Host plus first path segment, e.g. github.com/dataro. Falls back to the host. +export function sectionOf(url) { + const u = parseUrl(url); + if (!u || !u.hostname) { + return null; + } + const host = u.hostname.toLowerCase().replace(/^www\./, ''); + const first = u.pathname.split('/').filter(Boolean)[0]; + return first ? `${host}/${first}` : host; +} + +export function normalizeUrl(url, options = {}) { + const opts = { ...DEFAULT_NORMALIZATION, ...options }; + const u = parseUrl(url); + if (!u) { + return url || ''; + } + if (u.protocol !== 'http:' && u.protocol !== 'https:') { + return opts.ignoreFragment ? url.split('#')[0] : url; + } + + if (opts.ignoreScheme) { + u.protocol = 'https:'; + } + u.hostname = u.hostname.toLowerCase(); + if (opts.stripWww) { + u.hostname = u.hostname.replace(/^www\./, ''); + } + if (opts.ignoreFragment) { + u.hash = ''; + } + if (opts.stripTrackingParams || opts.sortQueryParams) { + const tracking = new Set( + (opts.trackingParams || []).map((p) => p.toLowerCase()) + ); + const kept = []; + for (const [k, v] of u.searchParams.entries()) { + if (opts.stripTrackingParams && tracking.has(k.toLowerCase())) { + continue; + } + kept.push([k, v]); + } + if (opts.sortQueryParams) { + kept.sort((a, b) => + a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : 1 + ); + } + const params = new URLSearchParams(); + kept.forEach(([k, v]) => params.append(k, v)); + const qs = params.toString(); + u.search = qs ? `?${qs}` : ''; + } + let out = u.toString(); + if (opts.ignoreTrailingSlash) { + // strip a trailing slash on the path, but keep the root "/" + out = out.replace(/\/(\?[^#]*)?$/, (m, q) => + u.pathname === '/' ? m : q || '' + ); + } + return out; +} + +// Compile rule definitions into regexes. Invalid rules are skipped and reported +// via the returned `errors` array so the options page can show them. +export function compileRules(rules) { + const compiled = []; + const errors = []; + (rules || []).forEach((rule, i) => { + if (!rule || rule.enabled === false) { + return; + } + if (!rule.match || !rule.key) { + errors.push({ index: i, rule, error: 'match and key are required' }); + return; + } + try { + compiled.push({ + name: rule.name || `Rule ${i + 1}`, + re: new RegExp(rule.match, rule.flags || 'i'), + key: rule.key + }); + } catch (e) { + errors.push({ index: i, rule, error: e.message }); + } + }); + return { compiled, errors }; +} + +export function expandTemplate(template, match) { + return template + .replace(/\$\{(\w+)\}/g, (m, name) => + match.groups && match.groups[name] !== undefined + ? match.groups[name] + : '' + ) + .replace(/\$(\d)/g, (m, n) => match[Number(n)] ?? ''); +} + +// Returns { key, rule } where rule is the matching rule name or null when only +// generic normalization applied. +export function canonicalize(url, compiledRules = [], normalization = {}) { + if (!url) { + return { key: '', rule: null }; + } + for (const rule of compiledRules) { + const match = rule.re.exec(url); + if (match) { + return { + key: expandTemplate(rule.key, match).toLowerCase(), + rule: rule.name + }; + } + } + return { key: normalizeUrl(url, normalization), rule: null }; +} + +export function matchingRule(url, compiledRules = []) { + for (const rule of compiledRules) { + if (rule.re.test(url)) { + return rule; + } + } + return null; +} + +// Which tab of a duplicate set to keep. Pinned beats active beats the policy. +export function survivorScore(tab, policy = 'recent') { + let score = 0; + if (tab.pinned) { + score += 1e15; + } + if (tab.active) { + score += 1e14; + } + if (policy === 'recent') { + score += tab.lastAccessed || 0; + } else if (policy === 'deepest') { + score += (tab.url || '').length; + } else if (policy === 'first') { + score -= (tab.windowId || 0) * 1e6 + (tab.index || 0); + } + return score; +} + +// Groups tabs by canonical key. Returns sets with more than one tab, each with +// a `keep` tab and `close` tabs. +export function findDuplicates(tabs, compiledRules, normalization, policy) { + const byKey = new Map(); + for (const tab of tabs) { + const url = tab.url || tab.pendingUrl || ''; + if (!url || isBlankUrl(url)) { + continue; + } + const { key, rule } = canonicalize(url, compiledRules, normalization); + // never treat a normal and an incognito tab as duplicates of each other + const scoped = `${tab.incognito ? 'i:' : 'n:'}${key}`; + if (!byKey.has(scoped)) { + byKey.set(scoped, { key, rule, tabs: [] }); + } + byKey.get(scoped).tabs.push(tab); + } + const sets = []; + for (const entry of byKey.values()) { + if (entry.tabs.length < 2) { + continue; + } + const sorted = [...entry.tabs].sort( + (a, b) => survivorScore(b, policy) - survivorScore(a, policy) + ); + sets.push({ + key: entry.key, + rule: entry.rule, + keep: sorted[0], + close: sorted.slice(1) + }); + } + return sets; +} + +// Describes the scopes a "close tabs like this one" action can use for a URL. +export function scopesFor(url, compiledRules = []) { + const host = hostOf(url); + if (!host) { + return []; + } + const scopes = [{ id: 'domain', label: host, test: (u) => hostOf(u) === host }]; + const section = sectionOf(url); + if (section && section !== host) { + scopes.push({ + id: 'section', + label: section, + test: (u) => sectionOf(u) === section + }); + } + const rule = matchingRule(url, compiledRules); + if (rule) { + scopes.push({ + id: 'entity', + label: `${rule.name}s`, + test: (u) => rule.re.test(u) + }); + } + return scopes; +} + +// Sort key that clusters related hosts: reversed host labels, then path, +// then query. "docs.google.com" sorts next to "mail.google.com". +export function sortKey(url) { + const u = parseUrl(url); + if (!u || !u.hostname) { + return `~${url || ''}`; + } + const host = u.hostname.toLowerCase().replace(/^www\./, ''); + const reversed = host.split('.').reverse().join('.'); + return `${reversed} ${u.pathname.toLowerCase()} ${u.search}`; +} + +export function compareTabs(a, b) { + const ka = sortKey(a.url || a.pendingUrl); + const kb = sortKey(b.url || b.pendingUrl); + return ka < kb ? -1 : ka > kb ? 1 : 0; +} + +export function isStale(tab, days, now = Date.now()) { + if (!tab.lastAccessed || days <= 0) { + return days <= 0 ? !tab.active : false; + } + return now - tab.lastAccessed > days * 86400000; +} + +// A short label for a tab group built from a host: "github" for github.com, +// "atlassian" for dataro.atlassian.net. +export function groupTitleForHost(host) { + if (!host) { + return ''; + } + const parts = host.split('.'); + if (parts.length <= 2) { + return parts[0]; + } + // drop common public suffix parts (co.uk, com.au ...) and keep the brand + const suffixes = new Set(['co', 'com', 'org', 'net', 'gov', 'edu', 'ac']); + let i = parts.length - 2; + if (suffixes.has(parts[i]) && parts[i + 1].length === 2 && i > 0) { + i -= 1; + } + return parts[i]; +} + +export const GROUP_COLORS = [ + 'grey', + 'blue', + 'red', + 'yellow', + 'green', + 'pink', + 'purple', + 'cyan', + 'orange' +]; + +export function colorForHost(host) { + let hash = 0; + for (const ch of host || '') { + hash = (hash * 31 + ch.charCodeAt(0)) >>> 0; + } + return GROUP_COLORS[hash % GROUP_COLORS.length]; +} diff --git a/src/options.css b/src/options.css new file mode 100644 index 0000000..9da3046 --- /dev/null +++ b/src/options.css @@ -0,0 +1,99 @@ +body { font-size: 14px; } +.page { display: flex; min-height: 100vh; max-width: 1100px; margin: 0 auto; } +.side { + width: 220px; flex: 0 0 220px; + padding: 28px 18px; + position: sticky; top: 0; align-self: flex-start; +} +.brand { display: flex; align-items: center; gap: 10px; margin-bottom: 22px; } +.brand b { display: block; font-size: 15px; } +.brand span { display: block; font-size: 12px; } +nav a { + display: block; padding: 8px 12px; border-radius: 8px; color: var(--text); font-weight: 500; +} +nav a:hover { background: var(--panel); text-decoration: none; } +nav a.on { background: var(--accent-soft); color: var(--accent); } +.save-state { margin-top: 18px; padding: 0 12px; font-size: 12px; min-height: 18px; } +main { flex: 1; min-width: 0; padding: 28px 24px 60px; } +.card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 14px; + padding: 22px 24px; + margin-bottom: 20px; + box-shadow: var(--shadow); +} +h2 { margin: 0 0 6px; font-size: 18px; letter-spacing: -0.01em; } +h3 { margin: 26px 0 6px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); } +.card > p.muted { margin: 0 0 14px; } +.row { + display: flex; align-items: center; gap: 20px; + padding: 14px 0; + border-top: 1px solid var(--line); +} +.row:first-of-type { border-top: 0; } +.row.col { flex-direction: column; align-items: stretch; gap: 8px; } +.row .lbl { flex: 1; min-width: 0; } +.row .lbl b { display: block; font-weight: 550; } +.row .lbl span { display: block; color: var(--muted); font-size: 13px; margin-top: 2px; } +.row input[type="number"] { width: 80px; } +.row input[type="text"], .row input[type="url"] { width: 100%; max-width: 520px; } +.row select { min-width: 220px; } +.inline { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } +.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px 16px; margin-bottom: 8px; } +.grid label { display: flex; align-items: center; gap: 8px; } +textarea { width: 100%; resize: vertical; } + +.switch { position: relative; display: inline-block; width: 40px; height: 24px; flex: 0 0 40px; } +.switch input { opacity: 0; width: 0; height: 0; } +.switch i { + position: absolute; inset: 0; border-radius: 999px; background: var(--line); transition: background 0.15s; +} +.switch i::after { + content: ''; position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%; + background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.25); transition: transform 0.15s; +} +.switch input:checked + i { background: var(--accent); } +.switch input:checked + i::after { transform: translateX(16px); } +.switch input:focus-visible + i { outline: 2px solid var(--accent); outline-offset: 2px; } + +.tester { margin: 12px 0 16px; } +.tester input { width: 100%; } +#test-result { margin-top: 6px; font-family: var(--mono); font-size: 12px; min-height: 18px; word-break: break-all; } + +.rules { display: flex; flex-direction: column; gap: 8px; } +.rule { + display: grid; + grid-template-columns: 24px 180px 1fr 1fr 28px; + gap: 8px; align-items: center; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--bg); +} +.rule.off { opacity: 0.55; } +.rule input[type="text"] { width: 100%; font-family: var(--mono); font-size: 12px; padding: 5px 8px; } +.rule input.name { font-family: var(--font); font-size: 13px; } +.rule .err { grid-column: 2 / -1; color: var(--danger); font-size: 12px; } +.rule .del { width: 28px; height: 28px; padding: 0; border-radius: 6px; color: var(--muted); } +.rule .del:hover { color: var(--danger); } +.rule .remote { grid-column: 1 / -1; } +.toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; } +.json-editor { margin-top: 12px; } +.remote-rules { margin-top: 20px; } + +.help-list { display: flex; flex-direction: column; gap: 10px; } +.help-item { display: flex; gap: 14px; padding: 10px 12px; background: var(--bg); border-radius: 10px; } +.help-item b { flex: 0 0 220px; font-weight: 550; } +.help-item span { color: var(--muted); } +.help-item kbd { margin-left: auto; } + +@media (max-width: 760px) { + .page { flex-direction: column; } + .side { width: auto; position: static; padding: 16px; } + nav { display: flex; flex-wrap: wrap; gap: 4px; } + .rule { grid-template-columns: 24px 1fr 28px; } + .rule input.match, .rule input.key { grid-column: 2 / 3; } + .help-item { flex-direction: column; gap: 4px; } + .help-item b { flex: none; } +} diff --git a/src/options.html b/src/options.html index 819fa95..788fd59 100644 --- a/src/options.html +++ b/src/options.html @@ -1,23 +1,165 @@ - + - - Organize Tabs - - - - -

- There are no options to configure for Organize Tab at this time. -

-

- Learn More -

- - - - + + + Organize Tabs Settings + + + + +
+ + +
+
+

General

+ +
+
Confirm before closingShow a preview list before any action that closes tabs from the popup. The context menu and keyboard shortcuts never show a preview.
+ +
+
+
Auto-deduplicate new tabsWhen you open a link that is already open somewhere, close the new tab and jump to the existing one. Uses the duplicate rules.
+ +
+
+
Duplicate count on the toolbar iconKeeps the badge updated as tabs open and close.
+ +
+
+
Which duplicate to keepPinned and active tabs always win. This decides between the rest.
+ +
+
+
A tab is stale afterUsed by "Close Stale Tabs" and "Free Memory". Pinned, active and playing tabs are never stale.
+
days
+
+
+
Minimum tabs per group"Group Tabs by Domain" only creates a group when a site has at least this many tabs in the window.
+ +
+
+
Collapse new groupsGroups created by "Group Tabs by Domain" start collapsed.
+ +
+
+
Leave meeting and media windows aloneConsolidate and Split skip any window that holds a tab playing audio or showing a meeting or video player, so a call stays put while you tidy around it.
+ +
+
+
Meeting and media pagesRegular expressions, one per line. A tab on one of these pages counts as media even when silent; any audible tab counts regardless.
+ +
+
+
Bookmark folder for parked windows
+ +
+ +

URL normalization

+

Two URLs that differ only in these ways count as the same page.

+
+ + + + + + +
+
+
Tracking parameters to ignoreOne per line or comma separated.
+ +
+
+ +
+

Duplicate rules

+

A rule turns many URLs into one key. Tabs with the same key are duplicates, and "Close Tabs Like This One" can close every tab matching a rule. match is a regular expression tested against the full URL and key is a template where $1…$9 or ${name} insert capture groups.

+ +
+ +
+
+ +
+
+ + + +
+ + +
+ +
+

Sync & backup

+

Settings and rules are saved to your browser profile's sync storage, so they follow you to every machine signed into the same Chrome or Brave profile.

+ +

Export and import

+

Move settings between profiles that do not sync with each other, or keep a copy in your dotfiles.

+
+ + + + +
+
+
Or paste JSON here
+ +
+
+ +

Remote rules file

+

Point at a JSON file you host, for example a raw file in a dotfiles repo or a gist. Its rules are merged in after your local rules and refreshed on a schedule. The file must be reachable without login.

+
+ +
+ Refresh every + + hours + + +
+
+
+ +
+

Help

+
+

Keyboard shortcuts

+

Shortcuts are managed by the browser. Open the shortcuts page to change them.

+
+

About

+

Organize Tabs is open source under the MIT license. Source, issues and rule contributions on GitHub.

+
+
+
+ + diff --git a/src/options.js b/src/options.js new file mode 100644 index 0000000..ed243a3 --- /dev/null +++ b/src/options.js @@ -0,0 +1,396 @@ +import { + DEFAULT_SETTINGS, + defaultRules, + exportBundle, + loadSettings, + parseImport, + saveSettings +} from './lib/settings.js'; +import { canonicalize, compileRules, normalizeUrl, TRACKING_PARAMS } from './lib/url.js'; + +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => [...document.querySelectorAll(sel)]; + +let state = { settings: { ...DEFAULT_SETTINGS }, rules: [], remoteRules: [], remoteRulesMeta: null, source: 'sync' }; +let saveTimer = null; +let ruleErrors = new Map(); + +function send(message) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (response) => { + if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message)); + else if (!response || !response.ok) reject(new Error(response?.error || 'No response')); + else resolve(response.result); + }); + }); +} + +function el(tag, attrs = {}, children = []) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') node.className = v; + else if (k === 'text') node.textContent = v; + else if (k.startsWith('on')) node.addEventListener(k.slice(2), v); + else if (v !== undefined && v !== null && v !== false) node.setAttribute(k, v === true ? '' : v); + } + for (const c of [].concat(children)) { + if (c === null || c === undefined) continue; + node.append(c.nodeType ? c : document.createTextNode(c)); + } + return node; +} + +// ----- SAVE -------------------- + +function setSaveState(text, cls = '') { + const node = $('#save-state'); + node.textContent = text; + node.className = `save-state ${cls || 'muted'}`; +} + +function scheduleSave() { + setSaveState('Saving…'); + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(save, 400); +} + +async function save() { + saveTimer = null; + try { + const where = await saveSettings({ settings: state.settings, rules: state.rules }); + state.source = where; + setSaveState(where === 'sync' ? 'Saved and synced' : 'Saved locally (sync unavailable)'); + renderSyncStatus(); + await send({ type: 'settingsChanged' }); + } catch (e) { + setSaveState(`Save failed: ${e.message}`, 'pill warn'); + } +} + +// ----- GENERAL -------------------- + +const BOOL_FIELDS = ['confirmClose', 'autoDedupe', 'showBadge', 'collapseGroups', 'protectMediaWindows']; +const NUM_FIELDS = ['staleDays', 'minGroupSize', 'remoteRefreshHours']; +const TEXT_FIELDS = ['survivorPolicy', 'parkFolderName', 'remoteRulesUrl']; +const NORM_FIELDS = ['ignoreScheme', 'stripWww', 'ignoreFragment', 'ignoreTrailingSlash', 'sortQueryParams', 'stripTrackingParams']; + +function renderGeneral() { + const s = state.settings; + BOOL_FIELDS.forEach((f) => { $(`#${f}`).checked = !!s[f]; }); + NUM_FIELDS.forEach((f) => { $(`#${f}`).value = s[f]; }); + TEXT_FIELDS.forEach((f) => { $(`#${f}`).value = s[f] ?? ''; }); + NORM_FIELDS.forEach((f) => { $(`#n-${f}`).checked = !!s.normalization[f]; }); + $('#trackingParams').value = (s.normalization.trackingParams || TRACKING_PARAMS).join('\n'); + $('#mediaPatterns').value = (s.mediaPatterns || []).join('\n'); +} + +function bindGeneral() { + BOOL_FIELDS.forEach((f) => $(`#${f}`).addEventListener('change', (e) => { state.settings[f] = e.target.checked; scheduleSave(); })); + NUM_FIELDS.forEach((f) => $(`#${f}`).addEventListener('change', (e) => { + const input = e.target; + const min = input.min === '' ? -Infinity : Number(input.min); + const max = input.max === '' ? Infinity : Number(input.max); + const n = Number(input.value); + const value = Number.isFinite(n) && input.value.trim() !== '' ? Math.min(max, Math.max(min, n)) : DEFAULT_SETTINGS[f]; + state.settings[f] = value; + input.value = value; + scheduleSave(); + })); + TEXT_FIELDS.forEach((f) => $(`#${f}`).addEventListener('change', (e) => { state.settings[f] = e.target.value.trim(); scheduleSave(); })); + NORM_FIELDS.forEach((f) => $(`#n-${f}`).addEventListener('change', (e) => { state.settings.normalization[f] = e.target.checked; scheduleSave(); runTester(); })); + $('#trackingParams').addEventListener('change', (e) => { + state.settings.normalization.trackingParams = e.target.value.split(/[\n,]/).map((p) => p.trim()).filter(Boolean); + scheduleSave(); + runTester(); + }); + $('#mediaPatterns').addEventListener('change', (e) => { + state.settings.mediaPatterns = e.target.value.split('\n').map((p) => p.trim()).filter(Boolean); + scheduleSave(); + }); + $('#remoteRulesUrl').addEventListener('change', async (e) => { + const url = e.target.value.trim(); + if (url) { + await requestOrigin(url); + } + }); +} + +// Ask for host permission on the remote rules origin so fetch works even when +// the host does not send CORS headers. +async function requestOrigin(url) { + try { + const origin = new URL(url).origin; + await chrome.permissions.request({ origins: [`${origin}/*`] }); + } catch (e) { + // user declined or the URL is malformed; fetch may still work with CORS + } +} + +// ----- RULES -------------------- + +function compiledRules() { + const all = [...state.rules, ...state.remoteRules]; + const { compiled, errors } = compileRules(all); + ruleErrors = new Map(errors.map((e) => [e.rule, e.error])); + return compiled; +} + +function ruleRow(rule, index, remote = false) { + const row = el('div', { class: `rule${rule.enabled === false ? ' off' : ''}` }); + const toggle = el('input', { type: 'checkbox', title: 'Enabled' }); + toggle.checked = rule.enabled !== false; + toggle.disabled = remote; + toggle.addEventListener('change', () => { rule.enabled = toggle.checked; row.classList.toggle('off', !toggle.checked); scheduleSave(); runTester(); }); + const name = el('input', { type: 'text', class: 'name', value: rule.name || '', placeholder: 'Name' }); + const match = el('input', { type: 'text', class: 'match', value: rule.match || '', placeholder: 'match (regex)' }); + const key = el('input', { type: 'text', class: 'key', value: rule.key || '', placeholder: 'key template, e.g. site:$1' }); + [name, match, key].forEach((input) => { + input.readOnly = remote; + input.addEventListener('change', () => { + rule.name = name.value.trim(); + rule.match = match.value; + rule.key = key.value; + delete rule.builtin; + renderRules(); + scheduleSave(); + runTester(); + }); + }); + const del = el('button', { class: 'del ghost', title: 'Delete rule', text: '×', onclick: () => { state.rules.splice(index, 1); renderRules(); scheduleSave(); runTester(); } }); + del.disabled = remote; + row.append(toggle, name, match, key, del); + const err = ruleErrors.get(rule); + if (err) { + row.append(el('div', { class: 'err', text: `Invalid: ${err}` })); + } + return row; +} + +function renderRules() { + compiledRules(); + const list = $('#rule-list'); + list.innerHTML = ''; + if (state.rules.length === 0) { + list.append(el('p', { class: 'muted', text: 'No rules. Duplicates are detected by normalized URL only.' })); + } + state.rules.forEach((rule, i) => list.append(ruleRow(rule, i))); + + const remoteWrap = $('#remote-rule-list'); + remoteWrap.hidden = state.remoteRules.length === 0; + const remoteList = remoteWrap.querySelector('.rules'); + remoteList.innerHTML = ''; + state.remoteRules.forEach((rule, i) => remoteList.append(ruleRow(rule, i, true))); +} + +function bindRules() { + $('#rule-add').addEventListener('click', () => { + state.rules.unshift({ name: '', match: '', key: '', enabled: true }); + renderRules(); + const first = $('#rule-list .rule input.name'); + if (first) first.focus(); + }); + $('#rule-reset').addEventListener('click', () => { + if (!confirm('Replace your rules with the built-in set?')) return; + state.rules = defaultRules(); + renderRules(); + scheduleSave(); + runTester(); + }); + $('#rule-json').addEventListener('click', () => { + $('#rule-json-text').value = JSON.stringify(state.rules.map(({ name, match, key, enabled }) => ({ name, match, key, enabled: enabled !== false })), null, 2); + $('#rule-json-error').hidden = true; + $('#rule-json-editor').hidden = false; + }); + $('#rule-json-cancel').addEventListener('click', () => { $('#rule-json-editor').hidden = true; }); + $('#rule-json-apply').addEventListener('click', () => { + try { + const parsed = parseImport($('#rule-json-text').value); + state.rules = parsed.rules || []; + $('#rule-json-editor').hidden = true; + renderRules(); + scheduleSave(); + runTester(); + } catch (e) { + const err = $('#rule-json-error'); + err.textContent = e.message; + err.hidden = false; + } + }); + $('#test-url').addEventListener('input', runTester); +} + +function runTester() { + const url = $('#test-url').value.trim(); + const out = $('#test-result'); + if (!url) { + out.textContent = ''; + return; + } + const compiled = compiledRules(); + const { key, rule } = canonicalize(url, compiled, state.settings.normalization); + out.textContent = rule + ? `${rule} → ${key}` + : `No rule matched. Normalized URL → ${normalizeUrl(url, state.settings.normalization)}`; +} + +// ----- SYNC & BACKUP -------------------- + +function renderSyncStatus() { + $('#sync-status').textContent = state.source === 'sync' + ? 'Sync storage is active.' + : 'Sync storage is not available in this profile, so settings are stored locally.'; +} + +function download(filename, text) { + const blob = new Blob([text], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = el('a', { href: url, download: filename }); + document.body.append(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function applyImport(text) { + const parsed = parseImport(text); + if (parsed.settings) state.settings = parsed.settings; + if (parsed.rules) state.rules = parsed.rules; + renderGeneral(); + renderRules(); + scheduleSave(); + runTester(); + return `Imported ${parsed.settings ? 'settings' : ''}${parsed.settings && parsed.rules ? ' and ' : ''}${parsed.rules ? `${parsed.rules.length} rules` : ''}.`; +} + +function bindSync() { + $('#export').addEventListener('click', () => { + const stamp = new Date().toISOString().slice(0, 10); + download(`organize-tabs-${stamp}.json`, JSON.stringify(exportBundle(state), null, 2)); + }); + $('#copy').addEventListener('click', async () => { + await navigator.clipboard.writeText(JSON.stringify(exportBundle(state), null, 2)); + setSaveState('Copied to clipboard'); + }); + $('#import').addEventListener('click', () => $('#import-file').click()); + $('#import-file').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + $('#import-result').textContent = applyImport(await file.text()); + } catch (err) { + $('#import-result').textContent = err.message; + } + e.target.value = ''; + }); + $('#import-apply').addEventListener('click', () => { + try { + $('#import-result').textContent = applyImport($('#import-text').value); + $('#import-text').value = ''; + } catch (err) { + $('#import-result').textContent = err.message; + } + }); + $('#remote-fetch').addEventListener('click', async () => { + const url = $('#remoteRulesUrl').value.trim(); + state.settings.remoteRulesUrl = url; + const status = $('#remote-status'); + if (!url) { + status.textContent = 'Enter a URL first.'; + return; + } + await requestOrigin(url); + await save(); + status.textContent = 'Fetching…'; + try { + const meta = await send({ type: 'fetchRemoteRules' }); + const loaded = await loadSettings(); + state.remoteRules = loaded.remoteRules; + state.remoteRulesMeta = meta; + renderRules(); + renderRemoteStatus(); + } catch (e) { + status.textContent = e.message; + } + }); +} + +function renderRemoteStatus() { + const meta = state.remoteRulesMeta; + const status = $('#remote-status'); + if (!state.settings.remoteRulesUrl) { + status.textContent = ''; + } else if (!meta) { + status.textContent = 'Not fetched yet.'; + } else if (meta.error) { + status.textContent = `Last attempt failed: ${meta.error}`; + } else { + status.textContent = `${meta.count} rules, fetched ${new Date(meta.fetchedAt).toLocaleString()}`; + } +} + +// ----- HELP -------------------- + +async function renderHelp() { + try { + const actions = await send({ type: 'actions' }); + const list = $('#help-list'); + list.innerHTML = ''; + for (const a of actions) { + list.append(el('div', { class: 'help-item' }, [el('b', { text: a.name }), el('span', { text: a.help })])); + } + } catch (e) { + $('#help-list').textContent = e.message; + } + try { + const commands = await chrome.commands.getAll(); + const list = $('#shortcut-list'); + list.innerHTML = ''; + for (const c of commands) { + list.append(el('div', { class: 'help-item' }, [ + el('b', { text: c.name === '_execute_action' ? 'Open Organize Tabs' : c.description || c.name }), + el('kbd', { text: c.shortcut || 'not set' }) + ])); + } + } catch (e) { /* ignore */ } + $('#open-shortcuts').addEventListener('click', (e) => { + e.preventDefault(); + chrome.tabs.create({ url: 'chrome://extensions/shortcuts' }); + }); +} + +// ----- NAV -------------------- + +function bindNav() { + const links = $$('nav a'); + const sections = links.map((a) => $(a.getAttribute('href'))); + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + links.forEach((a) => a.classList.toggle('on', a.getAttribute('href') === `#${entry.target.id}`)); + } + }); + }, { rootMargin: '-40% 0px -55% 0px' }); + sections.forEach((s) => observer.observe(s)); +} + +// ----- INIT -------------------- + +async function init() { + $('#version').textContent = `v${chrome.runtime.getManifest().version}`; + state = { ...state, ...(await loadSettings()) }; + renderGeneral(); + renderRules(); + renderSyncStatus(); + renderRemoteStatus(); + bindGeneral(); + bindRules(); + bindSync(); + bindNav(); + renderHelp(); + if (location.hash) { + const target = $(location.hash); + if (target) target.scrollIntoView(); + } +} + +init(); diff --git a/src/popup.css b/src/popup.css new file mode 100644 index 0000000..8419814 --- /dev/null +++ b/src/popup.css @@ -0,0 +1,117 @@ +body { width: 340px; } +.head { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px 10px; + background: var(--panel); + border-bottom: 1px solid var(--line); +} +.head-text { flex: 1; min-width: 0; } +.head h1 { margin: 0; font-size: 14px; font-weight: 650; letter-spacing: -0.01em; } +.stats { font-size: 11.5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.stats b { color: var(--text); font-weight: 600; } +button.icon { + width: 26px; height: 26px; padding: 0; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + font-weight: 700; +} +button.icon.on { background: var(--accent-soft); color: var(--accent); } + +main { padding: 8px 10px 4px; max-height: 480px; overflow-y: auto; } +.section { margin-bottom: 8px; } +.section h2 { + margin: 6px 6px 4px; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--muted); +} +.action { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + text-align: left; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid transparent; + background: transparent; +} +.action:hover, .action:focus-visible { background: var(--panel); border-color: var(--line); box-shadow: var(--shadow); } +.action .ic { width: 18px; height: 18px; flex: 0 0 18px; color: var(--accent); } +.action .ic svg { width: 18px; height: 18px; display: block; } +.action .lbl { flex: 1; min-width: 0; } +.action .lbl b { display: block; font-weight: 550; } +.action .lbl small { display: none; color: var(--muted); font-size: 11px; line-height: 1.35; margin-top: 2px; } +body.help .action .lbl small { display: block; } +.action .cnt { font-size: 11px; } +.action .cnt.zero { visibility: hidden; } +.action kbd { display: none; } +body.help .action kbd { display: inline-block; } + +.panel { + position: absolute; inset: 0; + background: var(--bg); + display: flex; flex-direction: column; + z-index: 5; +} +.panel[hidden] { display: none; } +.panel-head { + display: flex; align-items: center; gap: 8px; + padding: 12px 14px 8px; + background: var(--panel); + border-bottom: 1px solid var(--line); +} +.panel-title { flex: 1; font-weight: 650; font-size: 13.5px; } +#panel-body { flex: 1; overflow-y: auto; padding: 10px; } +.panel-foot { + display: flex; gap: 8px; justify-content: flex-end; + padding: 10px 12px; + background: var(--panel); + border-top: 1px solid var(--line); +} +.scopes { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; } +.scope { + display: flex; align-items: center; gap: 10px; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + cursor: pointer; +} +.scope.on { border-color: var(--accent); background: var(--accent-soft); } +.scope input { margin: 0; } +.scope .l { flex: 1; min-width: 0; } +.scope .l b { display: block; font-weight: 550; text-transform: capitalize; } +.scope .l span { display: block; color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.check { display: flex; align-items: center; gap: 8px; margin: 4px 2px 10px; color: var(--muted); } +.check input { margin: 0; } +.empty { padding: 24px 10px; text-align: center; color: var(--muted); } +.more { padding: 6px 10px; color: var(--muted); font-size: 11px; text-align: center; } + +.foot { + display: flex; align-items: center; gap: 12px; + padding: 8px 14px; + border-top: 1px solid var(--line); + background: var(--panel); + font-size: 12px; +} +.foot .spacer { flex: 1; } + +.toast { + position: absolute; left: 10px; right: 10px; bottom: 44px; + display: flex; align-items: center; gap: 10px; + padding: 9px 12px; + border-radius: 9px; + background: var(--text); + color: var(--bg); + box-shadow: var(--shadow); + z-index: 10; + font-weight: 500; +} +.toast[hidden] { display: none; } +.toast span { flex: 1; } +.toast button { border: 0; background: rgba(255,255,255,0.15); color: inherit; padding: 4px 10px; } +.toast.error { background: var(--danger); color: #fff; } diff --git a/src/popup.html b/src/popup.html index 7ecae65..a34e15a 100644 --- a/src/popup.html +++ b/src/popup.html @@ -1,62 +1,46 @@ - - + + Organize Tabs - - - - -

Organize Tabs

+ + + + +
+ +
+

Organize Tabs

+
Counting tabs…
+
+ +
- Choose an action: +
+
+
- + -

- Learn More -

+ - - - - + - + + diff --git a/src/popup.js b/src/popup.js new file mode 100644 index 0000000..6d04bb9 --- /dev/null +++ b/src/popup.js @@ -0,0 +1,383 @@ +// Popup: renders the action list, previews destructive actions, and sends +// everything to the service worker to run. + +import { loadSettings } from './lib/settings.js'; + +const GROUPS = [ + { id: 'organize', title: 'Organize' }, + { id: 'cleanup', title: 'Clean up' }, + { id: 'windows', title: 'Windows' } +]; + +const SHORTCUT_COMMANDS = new Set([ + 'dedupe', 'sortWindow', 'groupByDomain', 'closeScope', 'closeBlank', 'closeStale', + 'discardStale', 'consolidateAll', 'consolidateUnpinned', 'parkWindow', 'pullMedia', 'undo' +]); + +const ICONS = { + groupByDomain: 'M3 5h7v6H3zM14 5h7v6h-7zM3 13h7v6H3zM14 13h7v6h-7z', + sortWindow: 'M4 6h16M4 12h10M4 18h6M18 10v8m0 0l-3-3m3 3l3-3', + ungroupWindow: 'M4 8h6v8H4zM14 8h6v8h-6zM10 12h4', + consolidateAll: 'M4 4h16v6H4zM4 14h16v6H4zM12 10v4', + consolidateUnpinned: 'M4 4h16v6H4zM8 14h8v6H8z', + splitByDomain: 'M4 4h16v5H4zM4 15h7v5H4zM13 15h7v5h-7zM12 9v3', + dedupe: 'M7 7h10v10H7zM4 4h10v3M4 4v10h3M17 14l4 4M21 14l-4 4', + closeScope: 'M12 3l9 4.5v9L12 21l-9-4.5v-9zM9 10l6 6M15 10l-6 6', + closeStale: 'M12 3a9 9 0 110 18 9 9 0 010-18zM12 7v5l3 2', + discardStale: 'M5 4h14v4H5zM6 8l1 12h10l1-12M10 12v5M14 12v5', + closeBlank: 'M4 5h16v14H4zM9 10l6 6M15 10l-6 6', + parkWindow: 'M6 3h12v18l-6-4-6 4zM9 8h6', + restoreParked: 'M6 3h12v18l-6-4-6 4zM12 7v6M9 10l3 3 3-3', + pullMedia: 'M3 6h13v12H3zM16 10l5-3v10l-5-3M7 9.5v5l4-2.5z', + focusAllWindows: 'M3 8h12v11H3zM9 3h12v11h-3M9 3v5', + undo: 'M9 14l-4-4 4-4M5 10h9a5 5 0 010 10h-3' +}; + +const $ = (sel) => document.querySelector(sel); + +let actions = []; +let stats = null; +let currentTab = null; +let panelState = null; + +function send(message) { + return new Promise((resolve, reject) => { + chrome.runtime.sendMessage(message, (response) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + } else if (!response || !response.ok) { + reject(new Error(response?.error || 'No response')); + } else { + resolve(response.result); + } + }); + }); +} + +function el(tag, attrs = {}, children = []) { + const node = document.createElement(tag); + for (const [k, v] of Object.entries(attrs)) { + if (k === 'class') node.className = v; + else if (k === 'text') node.textContent = v; + else if (k === 'html') node.innerHTML = v; + else if (k.startsWith('on')) node.addEventListener(k.slice(2), v); + else if (v !== undefined && v !== null && v !== false) node.setAttribute(k, v === true ? '' : v); + } + for (const c of [].concat(children)) { + if (c === null || c === undefined) continue; + node.append(c.nodeType ? c : document.createTextNode(c)); + } + return node; +} + +function icon(id) { + const d = ICONS[id] || 'M4 12h16'; + const wrap = el('span', { class: 'ic' }); + wrap.innerHTML = ``; + return wrap; +} + +function countFor(actionId) { + if (!stats) return null; + switch (actionId) { + case 'dedupe': return stats.duplicates; + case 'closeBlank': return stats.blank; + case 'closeStale': return stats.stale; + case 'discardStale': return stats.stale; + case 'pullMedia': return stats.media; + case 'undo': return stats.undo ? stats.undo.count : 0; + default: return null; + } +} + +let shortcuts = {}; + +function renderActions() { + const root = $('#actions'); + root.innerHTML = ''; + for (const g of GROUPS) { + const list = actions.filter((a) => a.group === g.id); + if (list.length === 0) continue; + const section = el('div', { class: 'section' }, [el('h2', { text: g.title })]); + for (const a of list) { + const count = countFor(a.id); + const btn = el('button', { + class: 'action', + title: document.body.classList.contains('help') ? '' : a.help, + onclick: () => activate(a) + }, [ + icon(a.id), + el('span', { class: 'lbl' }, [ + el('b', { text: a.name }), + el('small', { text: a.help }) + ]), + shortcuts[a.id] ? el('kbd', { text: shortcuts[a.id] }) : null, + count !== null ? el('span', { class: `pill cnt ${count ? '' : 'zero'}`, text: String(count) }) : null + ]); + if (a.id === 'undo' && !(stats && stats.undo)) { + btn.disabled = true; + } + section.append(btn); + } + root.append(section); + } +} + +function renderStats() { + const s = stats; + const node = $('#stats'); + if (!s) { + node.textContent = 'Counting tabs…'; + return; + } + const parts = [ + `${s.tabs} tabs`, + `${s.windows} window${s.windows === 1 ? '' : 's'}`, + s.groups ? `${s.groups} group${s.groups === 1 ? '' : 's'}` : null, + s.duplicates ? `${s.duplicates} duplicate${s.duplicates === 1 ? '' : 's'}` : null, + s.stale ? `${s.stale} stale` : null + ].filter(Boolean); + node.innerHTML = parts.join(' · '); + const undo = $('#undo'); + undo.hidden = !s.undo; + if (s.undo) { + undo.textContent = `Undo (${s.undo.count})`; + } +} + +async function refresh() { + try { + stats = await send({ type: 'stats' }); + } catch (e) { + stats = null; + } + renderStats(); + renderActions(); +} + +let toastTimer = null; +function toast(message, { error = false, undo = false } = {}) { + const node = $('#toast'); + node.className = `toast${error ? ' error' : ''}`; + node.innerHTML = ''; + node.append(el('span', { text: message })); + if (undo) { + node.append(el('button', { text: 'Undo', onclick: () => run({ id: 'undo', name: 'Undo' }) })); + } + node.hidden = false; + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { node.hidden = true; }, undo ? 6000 : 3000); +} + +async function run(action, args = {}) { + try { + const result = await send({ type: 'run', action: action.id, args }); + closePanel(); + toast(result.message || 'Done', { undo: !!result.closed }); + await refresh(); + } catch (e) { + toast(e.message, { error: true }); + } +} + +function baseArgs() { + return currentTab ? { tabId: currentTab.id, windowId: currentTab.windowId } : {}; +} + +async function activate(action) { + const confirm = await needsConfirm(); + if (action.hasOptions) { + return openScopePanel(action); + } + if (action.hasPreview && confirm) { + return openPreview(action, baseArgs()); + } + return run(action, baseArgs()); +} + +async function needsConfirm() { + try { + const { settings } = await loadSettings(); + return settings.confirmClose !== false; + } catch (e) { + return true; + } +} + +function openPanel(title) { + $('#panel-title').textContent = title; + $('#panel-body').innerHTML = ''; + $('#panel').hidden = false; +} + +function closePanel() { + $('#panel').hidden = true; + panelState = null; +} + +function renderItems(items) { + if (!items || items.length === 0) { + return el('div', { class: 'empty', text: 'Nothing matches.' }); + } + const MAX = 60; + const list = el('ul', { class: 'list' }); + for (const it of items.slice(0, MAX)) { + const fav = it.favIconUrl && !it.favIconUrl.startsWith('chrome://') + ? el('img', { src: it.favIconUrl, alt: '' }) + : el('span', { class: 'fav' }); + list.append(el('li', {}, [ + fav, + el('span', { class: 't' }, [ + el('b', { text: it.title || it.url }), + el('span', { text: it.url }) + ]) + ])); + } + const wrap = el('div', {}, [list]); + if (items.length > MAX) { + wrap.append(el('div', { class: 'more', text: `and ${items.length - MAX} more` })); + } + return wrap; +} + +async function openPreview(action, args) { + openPanel(action.name); + $('#panel-body').append(el('div', { class: 'empty', text: 'Loading…' })); + try { + const preview = await send({ type: 'preview', action: action.id, args }); + $('#panel-title').textContent = preview.title || action.name; + $('#panel-body').innerHTML = ''; + $('#panel-body').append(renderItems(preview.items)); + const confirm = $('#panel-confirm'); + confirm.textContent = preview.confirmLabel || 'Confirm'; + confirm.disabled = !preview.items || preview.items.length === 0; + confirm.className = action.id === 'restoreParked' ? 'primary' : 'danger'; + panelState = { action, args }; + } catch (e) { + closePanel(); + toast(e.message, { error: true }); + } +} + +async function openScopePanel(action) { + openPanel(action.name); + $('#panel-body').append(el('div', { class: 'empty', text: 'Loading…' })); + try { + const opts = await send({ type: 'options', action: action.id, args: baseArgs() }); + const body = $('#panel-body'); + body.innerHTML = ''; + if (!opts.tab || opts.scopes.length === 0) { + body.append(el('div', { class: 'empty', text: 'The current tab has no closable scope.' })); + $('#panel-confirm').disabled = true; + return; + } + const state = { scope: opts.scopes[0].id, keepCurrent: true }; + const scopesNode = el('div', { class: 'scopes' }); + const listHolder = el('div'); + + let requestSeq = 0; + async function updateList() { + const seq = ++requestSeq; + listHolder.innerHTML = ''; + listHolder.append(el('div', { class: 'empty', text: 'Loading…' })); + const args = { ...baseArgs(), scope: state.scope, keepCurrent: state.keepCurrent }; + const preview = await send({ type: 'preview', action: action.id, args }); + if (seq !== requestSeq) { + return; // a newer selection superseded this response + } + listHolder.innerHTML = ''; + listHolder.append(renderItems(preview.items)); + const confirm = $('#panel-confirm'); + confirm.textContent = preview.confirmLabel; + confirm.disabled = preview.items.length === 0; + confirm.className = 'danger'; + panelState = { action, args }; + scopesNode.querySelectorAll('.scope').forEach((n) => { + n.classList.toggle('on', n.dataset.scope === state.scope); + }); + } + + for (const s of opts.scopes) { + const input = el('input', { type: 'radio', name: 'scope', value: s.id }); + input.checked = s.id === state.scope; + const row = el('label', { class: 'scope', 'data-scope': s.id }, [ + input, + el('span', { class: 'l' }, [ + el('b', { text: s.id === 'entity' ? 'Same kind of page' : s.id === 'section' ? 'Same section' : 'Same domain' }), + el('span', { text: s.label }) + ]), + el('span', { class: 'pill', text: String(s.count) }) + ]); + input.addEventListener('change', () => { state.scope = s.id; updateList(); }); + scopesNode.append(row); + } + const keep = el('input', { type: 'checkbox' }); + keep.checked = true; + keep.addEventListener('change', () => { state.keepCurrent = keep.checked; updateList(); }); + body.append(scopesNode, el('label', { class: 'check' }, [keep, 'Keep the current tab open']), listHolder); + await updateList(); + } catch (e) { + closePanel(); + toast(e.message, { error: true }); + } +} + +async function loadShortcuts() { + try { + const commands = await chrome.commands.getAll(); + for (const c of commands) { + if (c.shortcut && SHORTCUT_COMMANDS.has(c.name)) { + shortcuts[c.name] = c.shortcut; + } + } + } catch (e) { /* ignore */ } +} + +async function init() { + $('#panel-close').addEventListener('click', closePanel); + $('#panel-cancel').addEventListener('click', closePanel); + $('#panel-confirm').addEventListener('click', () => { + if (panelState) run(panelState.action, panelState.args); + }); + $('#undo').addEventListener('click', () => run({ id: 'undo', name: 'Undo' })); + $('#open-options').addEventListener('click', (e) => { + e.preventDefault(); + chrome.runtime.openOptionsPage(); + }); + + const helpToggle = $('#help-toggle'); + let helpOn = false; + try { + helpOn = (await chrome.storage.local.get('popupHelp')).popupHelp === true; + } catch (e) { /* ignore */ } + document.body.classList.toggle('help', helpOn); + helpToggle.classList.toggle('on', helpOn); + helpToggle.addEventListener('click', async () => { + helpOn = !helpOn; + document.body.classList.toggle('help', helpOn); + helpToggle.classList.toggle('on', helpOn); + renderActions(); + try { await chrome.storage.local.set({ popupHelp: helpOn }); } catch (e) { /* ignore */ } + }); + + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && !$('#panel').hidden) closePanel(); + }); + + try { + const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + currentTab = tab || null; + } catch (e) { currentTab = null; } + + await loadShortcuts(); + try { + actions = await send({ type: 'actions' }); + } catch (e) { + $('#actions').append(el('div', { class: 'empty', text: `Could not reach the extension: ${e.message}` })); + return; + } + renderActions(); + await refresh(); +} + +init(); diff --git a/src/ui.css b/src/ui.css new file mode 100644 index 0000000..240296d --- /dev/null +++ b/src/ui.css @@ -0,0 +1,127 @@ +/* Shared design tokens for popup and options. Light and dark via system preference. */ +:root { + --bg: #f6f7fb; + --panel: #ffffff; + --panel-2: #eef0f6; + --text: #15182b; + --muted: #6b7086; + --line: #dfe2ec; + --accent: #5b6cff; + --accent-2: #4453e0; + --accent-soft: #e9ebff; + --danger: #d64545; + --danger-soft: #fde8e8; + --ok: #1f9d55; + --radius: 10px; + --shadow: 0 1px 2px rgba(20, 24, 43, 0.06), 0 8px 24px rgba(20, 24, 43, 0.08); + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter, Helvetica, Arial, sans-serif; + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #12141c; + --panel: #1b1e29; + --panel-2: #232736; + --text: #eef0f6; + --muted: #9aa0b8; + --line: #2d3245; + --accent: #7d8bff; + --accent-2: #98a3ff; + --accent-soft: #262b4a; + --danger: #ff6b6b; + --danger-soft: #3a2224; + --ok: #4cd08a; + --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35); + } +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + font-family: var(--font); + font-size: 13px; + line-height: 1.4; + color: var(--text); + background: var(--bg); + -webkit-font-smoothing: antialiased; +} +a { color: var(--accent); text-decoration: none; } +a:hover { text-decoration: underline; } +button { + font: inherit; + color: inherit; + cursor: pointer; + border: 1px solid var(--line); + background: var(--panel); + border-radius: 8px; + padding: 6px 12px; +} +button:hover { background: var(--panel-2); } +button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #fff; + font-weight: 600; +} +button.primary:hover { background: var(--accent-2); border-color: var(--accent-2); } +button.danger { background: var(--danger); border-color: var(--danger); color: #fff; font-weight: 600; } +button.danger:hover { filter: brightness(0.95); } +button.ghost { background: transparent; border-color: transparent; color: var(--muted); } +button.ghost:hover { color: var(--text); background: var(--panel-2); } +button:disabled { opacity: 0.5; cursor: default; } +input[type="text"], input[type="number"], input[type="url"], select, textarea { + font: inherit; + color: var(--text); + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 7px 10px; +} +textarea { font-family: var(--mono); font-size: 12px; line-height: 1.45; } +code, kbd { font-family: var(--mono); font-size: 11.5px; } +kbd { + background: var(--panel-2); + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 5px; + padding: 1px 5px; + color: var(--muted); +} +.muted { color: var(--muted); } +.pill { + display: inline-block; + padding: 1px 8px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent); + font-weight: 600; + font-size: 11px; +} +.pill.warn { background: var(--danger-soft); color: var(--danger); } +.pill.ok { background: rgba(31, 157, 85, 0.12); color: var(--ok); } +.list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--panel); + overflow: hidden; +} +.list li { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-top: 1px solid var(--line); + min-width: 0; +} +.list li:first-child { border-top: 0; } +.list img { width: 16px; height: 16px; flex: 0 0 16px; border-radius: 3px; } +.list .fav { width: 16px; height: 16px; flex: 0 0 16px; border-radius: 3px; background: var(--panel-2); } +.list .t { flex: 1; min-width: 0; } +.list .t b { display: block; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.list .t span { display: block; color: var(--muted); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } diff --git a/src/vendor/bootstrap-4.5.2.min.css b/src/vendor/bootstrap-4.5.2.min.css deleted file mode 100644 index 21d10ba..0000000 --- a/src/vendor/bootstrap-4.5.2.min.css +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * Bootstrap v4.5.2 (https://getbootstrap.com/) - * Copyright 2011-2020 The Bootstrap Authors - * Copyright 2011-2020 Twitter, Inc. - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([class]){color:inherit;text-decoration:none}a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,small{font-size:80%;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote-footer{display:block;font-size:80%;color:#6c757d}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#6c757d}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:#212529}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-sm-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-sm-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-md-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-md-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-md-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-md-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-md-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-md-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-lg-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-lg-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-xl-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-xl-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.table{width:100%;margin-bottom:1rem;color:#212529}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid #dee2e6}.table thead th{vertical-align:bottom;border-bottom:2px solid #dee2e6}.table tbody+tbody{border-top:2px solid #dee2e6}.table-sm td,.table-sm th{padding:.3rem}.table-bordered{border:1px solid #dee2e6}.table-bordered td,.table-bordered th{border:1px solid #dee2e6}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{color:#212529;background-color:rgba(0,0,0,.075)}.table-primary,.table-primary>td,.table-primary>th{background-color:#b8daff}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#7abaff}.table-hover .table-primary:hover{background-color:#9fcdff}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#9fcdff}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#d6d8db}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#b3b7bb}.table-hover .table-secondary:hover{background-color:#c8cbcf}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#c8cbcf}.table-success,.table-success>td,.table-success>th{background-color:#c3e6cb}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#8fd19e}.table-hover .table-success:hover{background-color:#b1dfbb}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b1dfbb}.table-info,.table-info>td,.table-info>th{background-color:#bee5eb}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#86cfda}.table-hover .table-info:hover{background-color:#abdde5}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abdde5}.table-warning,.table-warning>td,.table-warning>th{background-color:#ffeeba}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ffdf7e}.table-hover .table-warning:hover{background-color:#ffe8a1}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffe8a1}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c6cb}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#ed969e}.table-hover .table-danger:hover{background-color:#f1b0b7}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1b0b7}.table-light,.table-light>td,.table-light>th{background-color:#fdfdfe}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#fbfcfc}.table-hover .table-light:hover{background-color:#ececf6}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#ececf6}.table-dark,.table-dark>td,.table-dark>th{background-color:#c6c8ca}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#95999c}.table-hover .table-dark:hover{background-color:#b9bbbe}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b9bbbe}.table-active,.table-active>td,.table-active>th{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,.075)}.table .thead-dark th{color:#fff;background-color:#343a40;border-color:#454d55}.table .thead-light th{color:#495057;background-color:#e9ecef;border-color:#dee2e6}.table-dark{color:#fff;background-color:#343a40}.table-dark td,.table-dark th,.table-dark thead th{border-color:#454d55}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,.075)}@media (max-width:575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width:991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #495057}.form-control:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.form-control::-webkit-input-placeholder{color:#6c757d;opacity:1}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control:-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:focus::-ms-value{color:#495057;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;font-size:1rem;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}select.form-control[multiple],select.form-control[size]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*=col-]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{color:#6c757d}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#28a745}.valid-tooltip{position:absolute;top:100%;left:0;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(40,167,69,.9);border-radius:.25rem}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#28a745;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-valid,.was-validated .custom-select:valid{border-color:#28a745;padding-right:calc(.75em + 2.3125rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#28a745}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#28a745}.custom-control-input.is-valid~.custom-control-label::before,.was-validated .custom-control-input:valid~.custom-control-label::before{border-color:#28a745}.custom-control-input.is-valid:checked~.custom-control-label::before,.was-validated .custom-control-input:valid:checked~.custom-control-label::before{border-color:#34ce57;background-color:#34ce57}.custom-control-input.is-valid:focus~.custom-control-label::before,.was-validated .custom-control-input:valid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label::before{border-color:#28a745}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#28a745}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:80%;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;left:0;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{border-color:#dc3545;padding-right:calc(.75em + 2.3125rem);background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px,url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") #fff no-repeat center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem)}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc3545}.custom-control-input.is-invalid~.custom-control-label::before,.was-validated .custom-control-input:invalid~.custom-control-label::before{border-color:#dc3545}.custom-control-input.is-invalid:checked~.custom-control-label::before,.was-validated .custom-control-input:invalid:checked~.custom-control-label::before{border-color:#e4606d;background-color:#e4606d}.custom-control-input.is-invalid:focus~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label::before{border-color:#dc3545}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc3545}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width:576px){.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#212529;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529;text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:hover{color:#fff;background-color:#0069d9;border-color:#0062cc}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#0069d9;border-color:#0062cc;box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0062cc;border-color:#005cbf}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5a6268;border-color:#545b62}.btn-secondary.focus,.btn-secondary:focus{color:#fff;background-color:#5a6268;border-color:#545b62;box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#545b62;border-color:#4e555b}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-success{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:hover{color:#fff;background-color:#218838;border-color:#1e7e34}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#218838;border-color:#1e7e34;box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#1e7e34;border-color:#1c7430}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-info{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:hover{color:#fff;background-color:#138496;border-color:#117a8b}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#138496;border-color:#117a8b;box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#117a8b;border-color:#10707f}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-warning{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#212529;background-color:#e0a800;border-color:#d39e00}.btn-warning.focus,.btn-warning:focus{color:#212529;background-color:#e0a800;border-color:#d39e00;box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{color:#212529;background-color:#d39e00;border-color:#c69500}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#c82333;border-color:#bd2130}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c82333;border-color:#bd2130;box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#bd2130;border-color:#b21f2d}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-light{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#212529;background-color:#e2e6ea;border-color:#dae0e5}.btn-light.focus,.btn-light:focus{color:#212529;background-color:#e2e6ea;border-color:#dae0e5;box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-light.disabled,.btn-light:disabled{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{color:#212529;background-color:#dae0e5;border-color:#d3d9df}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-dark{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:hover{color:#fff;background-color:#23272b;border-color:#1d2124}.btn-dark.focus,.btn-dark:focus{color:#fff;background-color:#23272b;border-color:#1d2124;box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1d2124;border-color:#171a1d}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-outline-primary{color:#007bff;border-color:#007bff}.btn-outline-primary:hover{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#007bff;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-success{color:#28a745;border-color:#28a745}.btn-outline-success:hover{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#28a745;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-info{color:#17a2b8;border-color:#17a2b8}.btn-outline-info:hover{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#17a2b8;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-dark{color:#343a40;border-color:#343a40}.btn-outline-dark:hover{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#343a40;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-link{font-weight:400;color:#007bff;text-decoration:none}.btn-link:hover{color:#0056b3;text-decoration:underline}.btn-link.focus,.btn-link:focus{text-decoration:underline}.btn-link.disabled,.btn-link:disabled{color:#6c757d;pointer-events:none}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #e9ecef}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#212529;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#16181b;text-decoration:none;background-color:#f8f9fa}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#007bff}.dropdown-item.disabled,.dropdown-item:disabled{color:#6c757d;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1.5rem;color:#212529}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group-vertical>.btn:hover,.btn-group>.btn:hover{z-index:1}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus{z-index:1}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;min-width:0;margin-bottom:0}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:last-child),.input-group>.form-control:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group-append,.input-group-prepend{display:-ms-flexbox;display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-append:not(:last-child)>.btn,.input-group>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;z-index:1;display:block;min-height:1.5rem;padding-left:1.5rem}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;left:0;z-index:-1;width:1rem;height:1.25rem;opacity:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;border-color:#007bff;background-color:#007bff}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label::before{border-color:#80bdff}.custom-control-input:not(:disabled):active~.custom-control-label::before{color:#fff;background-color:#b3d7ff;border-color:#b3d7ff}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#6c757d}.custom-control-input:disabled~.custom-control-label::before,.custom-control-input[disabled]~.custom-control-label::before{background-color:#e9ecef}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:#adb5bd solid 1px}.custom-control-label::after{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:no-repeat 50%/50% 50%}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{border-color:#007bff;background-color:#007bff}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:.5rem}.custom-switch .custom-control-label::after{top:calc(.25rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#adb5bd;border-radius:.5rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label::after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label::after{background-color:#fff;-webkit-transform:translateX(.75rem);transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 1.75rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;vertical-align:middle;background:#fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right .75rem center/8px 10px;border:1px solid #ced4da;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-select:focus::-ms-value{color:#495057;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:.75rem;background-image:none}.custom-select:disabled{color:#6c757d;background-color:#e9ecef}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #495057}.custom-select-sm{height:calc(1.5em + .5rem + 2px);padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + .75rem + 2px);margin:0;opacity:0}.custom-file-input:focus~.custom-file-label{border-color:#80bdff;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-file-input:disabled~.custom-file-label,.custom-file-input[disabled]~.custom-file-label{background-color:#e9ecef}.custom-file-input:lang(en)~.custom-file-label::after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + .75rem);padding:.375rem .75rem;line-height:1.5;color:#495057;content:"Browse";background-color:#e9ecef;border-left:inherit;border-radius:0 .25rem .25rem 0}.custom-range{width:100%;height:1.4rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#007bff;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#b3d7ff}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#007bff;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{-moz-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#b3d7ff}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:.2rem;margin-left:.2rem;background-color:#007bff;border:0;border-radius:1rem;-ms-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{-ms-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#b3d7ff}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower{background-color:#dee2e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dee2e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#adb5bd}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#adb5bd}.custom-control-label::before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label::before,.custom-file-label,.custom-select{transition:none}}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-item{margin-bottom:-1px}.nav-tabs .nav-link{border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{border-color:#e9ecef #e9ecef #dee2e6}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#007bff}.nav-fill .nav-item,.nav-fill>.nav-link{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar .container,.navbar .container-fluid,.navbar .container-lg,.navbar .container-md,.navbar .container-sm,.navbar .container-xl{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;line-height:inherit;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:no-repeat center center;background-size:100% 100%}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{padding-right:0;padding-left:0}}@media (min-width:768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{padding-right:0;padding-left:0}}@media (min-width:992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{padding-right:0;padding-left:0}}@media (min-width:1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.5);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a{color:rgba(0,0,0,.9)}.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.5);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{-ms-flex:1 1 auto;flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{-ms-flex-negative:0;flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck .card{margin-bottom:15px}@media (min-width:576px){.card-deck{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{-ms-flex:1 0 0%;flex:1 0 0%;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group>.card{margin-bottom:15px}@media (min-width:576px){.card-group{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion{overflow-anchor:none}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#e9ecef;border-radius:.25rem}.breadcrumb-item{display:-ms-flexbox;display:flex}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{display:inline-block;padding-right:.5rem;color:#6c757d;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#6c757d}.pagination{display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#007bff;background-color:#fff;border:1px solid #dee2e6}.page-link:hover{z-index:2;color:#0056b3;text-decoration:none;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:3;color:#fff;background-color:#007bff;border-color:#007bff}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dee2e6}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#007bff}a.badge-primary:focus,a.badge-primary:hover{color:#fff;background-color:#0062cc}a.badge-primary.focus,a.badge-primary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.badge-secondary{color:#fff;background-color:#6c757d}a.badge-secondary:focus,a.badge-secondary:hover{color:#fff;background-color:#545b62}a.badge-secondary.focus,a.badge-secondary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.badge-success{color:#fff;background-color:#28a745}a.badge-success:focus,a.badge-success:hover{color:#fff;background-color:#1e7e34}a.badge-success.focus,a.badge-success:focus{outline:0;box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.badge-info{color:#fff;background-color:#17a2b8}a.badge-info:focus,a.badge-info:hover{color:#fff;background-color:#117a8b}a.badge-info.focus,a.badge-info:focus{outline:0;box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.badge-warning{color:#212529;background-color:#ffc107}a.badge-warning:focus,a.badge-warning:hover{color:#212529;background-color:#d39e00}a.badge-warning.focus,a.badge-warning:focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.badge-danger{color:#fff;background-color:#dc3545}a.badge-danger:focus,a.badge-danger:hover{color:#fff;background-color:#bd2130}a.badge-danger.focus,a.badge-danger:focus{outline:0;box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.badge-light{color:#212529;background-color:#f8f9fa}a.badge-light:focus,a.badge-light:hover{color:#212529;background-color:#dae0e5}a.badge-light.focus,a.badge-light:focus{outline:0;box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.badge-dark{color:#fff;background-color:#343a40}a.badge-dark:focus,a.badge-dark:hover{color:#fff;background-color:#1d2124}a.badge-dark.focus,a.badge-dark:focus{outline:0;box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#e9ecef;border-radius:.3rem}@media (min-width:576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{position:absolute;top:0;right:0;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#004085;background-color:#cce5ff;border-color:#b8daff}.alert-primary hr{border-top-color:#9fcdff}.alert-primary .alert-link{color:#002752}.alert-secondary{color:#383d41;background-color:#e2e3e5;border-color:#d6d8db}.alert-secondary hr{border-top-color:#c8cbcf}.alert-secondary .alert-link{color:#202326}.alert-success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.alert-success hr{border-top-color:#b1dfbb}.alert-success .alert-link{color:#0b2e13}.alert-info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.alert-info hr{border-top-color:#abdde5}.alert-info .alert-link{color:#062c33}.alert-warning{color:#856404;background-color:#fff3cd;border-color:#ffeeba}.alert-warning hr{border-top-color:#ffe8a1}.alert-warning .alert-link{color:#533f03}.alert-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.alert-danger hr{border-top-color:#f1b0b7}.alert-danger .alert-link{color:#491217}.alert-light{color:#818182;background-color:#fefefe;border-color:#fdfdfe}.alert-light hr{border-top-color:#ececf6}.alert-light .alert-link{color:#686868}.alert-dark{color:#1b1e21;background-color:#d6d8d9;border-color:#c6c8ca}.alert-dark hr{border-top-color:#b9bbbe}.alert-dark .alert-link{color:#040505}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;line-height:0;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#007bff;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:progress-bar-stripes 1s linear infinite;animation:progress-bar-stripes 1s linear infinite}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#007bff;border-color:#007bff}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#004085;background-color:#b8daff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#004085;background-color:#9fcdff}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#004085;border-color:#004085}.list-group-item-secondary{color:#383d41;background-color:#d6d8db}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#383d41;background-color:#c8cbcf}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#383d41;border-color:#383d41}.list-group-item-success{color:#155724;background-color:#c3e6cb}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#155724;background-color:#b1dfbb}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#155724;border-color:#155724}.list-group-item-info{color:#0c5460;background-color:#bee5eb}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#0c5460;background-color:#abdde5}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#0c5460;border-color:#0c5460}.list-group-item-warning{color:#856404;background-color:#ffeeba}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#856404;background-color:#ffe8a1}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#856404;border-color:#856404}.list-group-item-danger{color:#721c24;background-color:#f5c6cb}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#721c24;background-color:#f1b0b7}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#721c24;border-color:#721c24}.list-group-item-light{color:#818182;background-color:#fdfdfe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#818182;background-color:#ececf6}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#818182;border-color:#818182}.list-group-item-dark{color:#1b1e21;background-color:#c6c8ca}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#1b1e21;background-color:#b9bbbe}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#1b1e21;border-color:#1b1e21}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{padding:0;background-color:transparent;border:0}a.close.disabled{pointer-events:none}.toast{-ms-flex-preferred-size:350px;flex-basis:350px;max-width:350px;font-size:.875rem;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .25rem .75rem rgba(0,0,0,.1);opacity:0;border-radius:.25rem}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.25rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out;-webkit-transform:translate(0,-50px);transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal.modal-static .modal-dialog{-webkit-transform:scale(1.02);transform:scale(1.02)}.modal-dialog-scrollable{display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);height:-webkit-min-content;height:-moz-min-content;height:min-content;content:""}.modal-dialog-centered.modal-dialog-scrollable{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem);height:-webkit-min-content;height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow::before,.bs-tooltip-top .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=right] .arrow::before,.bs-tooltip-right .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow::before,.bs-tooltip-bottom .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=left] .arrow::before,.bs-tooltip-left .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;word-spacing:normal;white-space:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::after,.popover .arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=top]>.arrow::before,.bs-popover-top>.arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=top]>.arrow::after,.bs-popover-top>.arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=right]>.arrow::before,.bs-popover-right>.arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=right]>.arrow::after,.bs-popover-right>.arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=bottom]>.arrow::before,.bs-popover-bottom>.arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=bottom]>.arrow::after,.bs-popover-bottom>.arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[x-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f7f7f7}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=left]>.arrow::before,.bs-popover-left>.arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=left]>.arrow::after,.bs-popover-left>.arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:1rem;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{-ms-touch-action:pan-y;touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;transition:transform .6s ease-in-out,-webkit-transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){-webkit-transform:translateX(100%);transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){-webkit-transform:translateX(-100%);transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;-webkit-transform:none;transform:none}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:15%;color:#fff;text-align:center;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:20px;height:20px;background:no-repeat 50%/100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:15;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{box-sizing:content-box;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;border:.25em solid currentColor;border-right-color:transparent;border-radius:50%;-webkit-animation:spinner-border .75s linear infinite;animation:spinner-border .75s linear infinite}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:text-bottom;background-color:currentColor;border-radius:50%;opacity:0;-webkit-animation:spinner-grow .75s linear infinite;animation:spinner-grow .75s linear infinite}.spinner-grow-sm{width:1rem;height:1rem}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#007bff!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#0062cc!important}.bg-secondary{background-color:#6c757d!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#545b62!important}.bg-success{background-color:#28a745!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#1e7e34!important}.bg-info{background-color:#17a2b8!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#117a8b!important}.bg-warning{background-color:#ffc107!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#d39e00!important}.bg-danger{background-color:#dc3545!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#bd2130!important}.bg-light{background-color:#f8f9fa!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#dae0e5!important}.bg-dark{background-color:#343a40!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#1d2124!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #dee2e6!important}.border-top{border-top:1px solid #dee2e6!important}.border-right{border-right:1px solid #dee2e6!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-left{border-left:1px solid #dee2e6!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#007bff!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#28a745!important}.border-info{border-color:#17a2b8!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#343a40!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-right{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-lg{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:-ms-flexbox!important;display:flex!important}.d-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}@media (min-width:576px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:-ms-flexbox!important;display:flex!important}.d-sm-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:768px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:-ms-flexbox!important;display:flex!important}.d-md-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:-ms-flexbox!important;display:flex!important}.d-lg-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:1200px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:-ms-flexbox!important;display:flex!important}.d-xl-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:-ms-flexbox!important;display:flex!important}.d-print-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.857143%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-sm-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-sm-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-sm-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-sm-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-sm-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-sm-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-md-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-md-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-md-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-md-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-md-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-md-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-lg-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-lg-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-lg-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-lg-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-lg-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-lg-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-xl-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-xl-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-xl-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-xl-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-xl-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-xl-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;-ms-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;-ms-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;-ms-user-select:none!important;user-select:none!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position:-webkit-sticky) or (position:sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:576px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:768px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:992px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:1200px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:700!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#007bff!important}a.text-primary:focus,a.text-primary:hover{color:#0056b3!important}.text-secondary{color:#6c757d!important}a.text-secondary:focus,a.text-secondary:hover{color:#494f54!important}.text-success{color:#28a745!important}a.text-success:focus,a.text-success:hover{color:#19692c!important}.text-info{color:#17a2b8!important}a.text-info:focus,a.text-info:hover{color:#0f6674!important}.text-warning{color:#ffc107!important}a.text-warning:focus,a.text-warning:hover{color:#ba8b00!important}.text-danger{color:#dc3545!important}a.text-danger:focus,a.text-danger:hover{color:#a71d2a!important}.text-light{color:#f8f9fa!important}a.text-light:focus,a.text-light:hover{color:#cbd3da!important}.text-dark{color:#343a40!important}a.text-dark:focus,a.text-dark:hover{color:#121416!important}.text-body{color:#212529!important}.text-muted{color:#6c757d!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:rgba(255,255,255,.5)!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none!important}.text-break{word-break:break-word!important;overflow-wrap:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #adb5bd;page-break-inside:avoid}thead{display:table-header-group}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #dee2e6!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#dee2e6}.table .thead-dark th{color:inherit;border-color:#dee2e6}} -/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/src/vendor/bootstrap-4.5.2.min.js b/src/vendor/bootstrap-4.5.2.min.js deleted file mode 100644 index ef4d9cb..0000000 --- a/src/vendor/bootstrap-4.5.2.min.js +++ /dev/null @@ -1,7 +0,0 @@ -/*! - * Bootstrap v4.5.2 (https://getbootstrap.com/) - * Copyright 2011-2020 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */ -!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("jquery"),require("popper.js")):"function"==typeof define&&define.amd?define(["exports","jquery","popper.js"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap={},t.jQuery,t.Popper)}(this,(function(t,e,n){"use strict";function i(t,e){for(var n=0;n=4)throw new Error("Bootstrap's JavaScript requires at least jQuery v1.9.1 but less than v4.0.0")}};a.jQueryDetection(),e.fn.emulateTransitionEnd=r,e.event.special[a.TRANSITION_END]={bindType:"transitionend",delegateType:"transitionend",handle:function(t){if(e(t.target).is(this))return t.handleObj.handler.apply(this,arguments)}};var l="alert",c=e.fn[l],h=function(){function t(t){this._element=t}var n=t.prototype;return n.close=function(t){var e=this._element;t&&(e=this._getRootElement(t)),this._triggerCloseEvent(e).isDefaultPrevented()||this._removeElement(e)},n.dispose=function(){e.removeData(this._element,"bs.alert"),this._element=null},n._getRootElement=function(t){var n=a.getSelectorFromElement(t),i=!1;return n&&(i=document.querySelector(n)),i||(i=e(t).closest(".alert")[0]),i},n._triggerCloseEvent=function(t){var n=e.Event("close.bs.alert");return e(t).trigger(n),n},n._removeElement=function(t){var n=this;if(e(t).removeClass("show"),e(t).hasClass("fade")){var i=a.getTransitionDurationFromElement(t);e(t).one(a.TRANSITION_END,(function(e){return n._destroyElement(t,e)})).emulateTransitionEnd(i)}else this._destroyElement(t)},n._destroyElement=function(t){e(t).detach().trigger("closed.bs.alert").remove()},t._jQueryInterface=function(n){return this.each((function(){var i=e(this),o=i.data("bs.alert");o||(o=new t(this),i.data("bs.alert",o)),"close"===n&&o[n](this)}))},t._handleDismiss=function(t){return function(e){e&&e.preventDefault(),t.close(this)}},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}}]),t}();e(document).on("click.bs.alert.data-api",'[data-dismiss="alert"]',h._handleDismiss(new h)),e.fn[l]=h._jQueryInterface,e.fn[l].Constructor=h,e.fn[l].noConflict=function(){return e.fn[l]=c,h._jQueryInterface};var u=e.fn.button,d=function(){function t(t){this._element=t}var n=t.prototype;return n.toggle=function(){var t=!0,n=!0,i=e(this._element).closest('[data-toggle="buttons"]')[0];if(i){var o=this._element.querySelector('input:not([type="hidden"])');if(o){if("radio"===o.type)if(o.checked&&this._element.classList.contains("active"))t=!1;else{var s=i.querySelector(".active");s&&e(s).removeClass("active")}t&&("checkbox"!==o.type&&"radio"!==o.type||(o.checked=!this._element.classList.contains("active")),e(o).trigger("change")),o.focus(),n=!1}}this._element.hasAttribute("disabled")||this._element.classList.contains("disabled")||(n&&this._element.setAttribute("aria-pressed",!this._element.classList.contains("active")),t&&e(this._element).toggleClass("active"))},n.dispose=function(){e.removeData(this._element,"bs.button"),this._element=null},t._jQueryInterface=function(n){return this.each((function(){var i=e(this).data("bs.button");i||(i=new t(this),e(this).data("bs.button",i)),"toggle"===n&&i[n]()}))},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}}]),t}();e(document).on("click.bs.button.data-api",'[data-toggle^="button"]',(function(t){var n=t.target,i=n;if(e(n).hasClass("btn")||(n=e(n).closest(".btn")[0]),!n||n.hasAttribute("disabled")||n.classList.contains("disabled"))t.preventDefault();else{var o=n.querySelector('input:not([type="hidden"])');if(o&&(o.hasAttribute("disabled")||o.classList.contains("disabled")))return void t.preventDefault();("LABEL"!==i.tagName||o&&"checkbox"!==o.type)&&d._jQueryInterface.call(e(n),"toggle")}})).on("focus.bs.button.data-api blur.bs.button.data-api",'[data-toggle^="button"]',(function(t){var n=e(t.target).closest(".btn")[0];e(n).toggleClass("focus",/^focus(in)?$/.test(t.type))})),e(window).on("load.bs.button.data-api",(function(){for(var t=[].slice.call(document.querySelectorAll('[data-toggle="buttons"] .btn')),e=0,n=t.length;e0,this._pointerEvent=Boolean(window.PointerEvent||window.MSPointerEvent),this._addEventListeners()}var n=t.prototype;return n.next=function(){this._isSliding||this._slide("next")},n.nextWhenVisible=function(){!document.hidden&&e(this._element).is(":visible")&&"hidden"!==e(this._element).css("visibility")&&this.next()},n.prev=function(){this._isSliding||this._slide("prev")},n.pause=function(t){t||(this._isPaused=!0),this._element.querySelector(".carousel-item-next, .carousel-item-prev")&&(a.triggerTransitionEnd(this._element),this.cycle(!0)),clearInterval(this._interval),this._interval=null},n.cycle=function(t){t||(this._isPaused=!1),this._interval&&(clearInterval(this._interval),this._interval=null),this._config.interval&&!this._isPaused&&(this._interval=setInterval((document.visibilityState?this.nextWhenVisible:this.next).bind(this),this._config.interval))},n.to=function(t){var n=this;this._activeElement=this._element.querySelector(".active.carousel-item");var i=this._getItemIndex(this._activeElement);if(!(t>this._items.length-1||t<0))if(this._isSliding)e(this._element).one("slid.bs.carousel",(function(){return n.to(t)}));else{if(i===t)return this.pause(),void this.cycle();var o=t>i?"next":"prev";this._slide(o,this._items[t])}},n.dispose=function(){e(this._element).off(g),e.removeData(this._element,"bs.carousel"),this._items=null,this._config=null,this._element=null,this._interval=null,this._isPaused=null,this._isSliding=null,this._activeElement=null,this._indicatorsElement=null},n._getConfig=function(t){return t=s({},p,t),a.typeCheckConfig(f,t,_),t},n._handleSwipe=function(){var t=Math.abs(this.touchDeltaX);if(!(t<=40)){var e=t/this.touchDeltaX;this.touchDeltaX=0,e>0&&this.prev(),e<0&&this.next()}},n._addEventListeners=function(){var t=this;this._config.keyboard&&e(this._element).on("keydown.bs.carousel",(function(e){return t._keydown(e)})),"hover"===this._config.pause&&e(this._element).on("mouseenter.bs.carousel",(function(e){return t.pause(e)})).on("mouseleave.bs.carousel",(function(e){return t.cycle(e)})),this._config.touch&&this._addTouchEventListeners()},n._addTouchEventListeners=function(){var t=this;if(this._touchSupported){var n=function(e){t._pointerEvent&&v[e.originalEvent.pointerType.toUpperCase()]?t.touchStartX=e.originalEvent.clientX:t._pointerEvent||(t.touchStartX=e.originalEvent.touches[0].clientX)},i=function(e){t._pointerEvent&&v[e.originalEvent.pointerType.toUpperCase()]&&(t.touchDeltaX=e.originalEvent.clientX-t.touchStartX),t._handleSwipe(),"hover"===t._config.pause&&(t.pause(),t.touchTimeout&&clearTimeout(t.touchTimeout),t.touchTimeout=setTimeout((function(e){return t.cycle(e)}),500+t._config.interval))};e(this._element.querySelectorAll(".carousel-item img")).on("dragstart.bs.carousel",(function(t){return t.preventDefault()})),this._pointerEvent?(e(this._element).on("pointerdown.bs.carousel",(function(t){return n(t)})),e(this._element).on("pointerup.bs.carousel",(function(t){return i(t)})),this._element.classList.add("pointer-event")):(e(this._element).on("touchstart.bs.carousel",(function(t){return n(t)})),e(this._element).on("touchmove.bs.carousel",(function(e){return function(e){e.originalEvent.touches&&e.originalEvent.touches.length>1?t.touchDeltaX=0:t.touchDeltaX=e.originalEvent.touches[0].clientX-t.touchStartX}(e)})),e(this._element).on("touchend.bs.carousel",(function(t){return i(t)})))}},n._keydown=function(t){if(!/input|textarea/i.test(t.target.tagName))switch(t.which){case 37:t.preventDefault(),this.prev();break;case 39:t.preventDefault(),this.next()}},n._getItemIndex=function(t){return this._items=t&&t.parentNode?[].slice.call(t.parentNode.querySelectorAll(".carousel-item")):[],this._items.indexOf(t)},n._getItemByDirection=function(t,e){var n="next"===t,i="prev"===t,o=this._getItemIndex(e),s=this._items.length-1;if((i&&0===o||n&&o===s)&&!this._config.wrap)return e;var r=(o+("prev"===t?-1:1))%this._items.length;return-1===r?this._items[this._items.length-1]:this._items[r]},n._triggerSlideEvent=function(t,n){var i=this._getItemIndex(t),o=this._getItemIndex(this._element.querySelector(".active.carousel-item")),s=e.Event("slide.bs.carousel",{relatedTarget:t,direction:n,from:o,to:i});return e(this._element).trigger(s),s},n._setActiveIndicatorElement=function(t){if(this._indicatorsElement){var n=[].slice.call(this._indicatorsElement.querySelectorAll(".active"));e(n).removeClass("active");var i=this._indicatorsElement.children[this._getItemIndex(t)];i&&e(i).addClass("active")}},n._slide=function(t,n){var i,o,s,r=this,l=this._element.querySelector(".active.carousel-item"),c=this._getItemIndex(l),h=n||l&&this._getItemByDirection(t,l),u=this._getItemIndex(h),d=Boolean(this._interval);if("next"===t?(i="carousel-item-left",o="carousel-item-next",s="left"):(i="carousel-item-right",o="carousel-item-prev",s="right"),h&&e(h).hasClass("active"))this._isSliding=!1;else if(!this._triggerSlideEvent(h,s).isDefaultPrevented()&&l&&h){this._isSliding=!0,d&&this.pause(),this._setActiveIndicatorElement(h);var f=e.Event("slid.bs.carousel",{relatedTarget:h,direction:s,from:c,to:u});if(e(this._element).hasClass("slide")){e(h).addClass(o),a.reflow(h),e(l).addClass(i),e(h).addClass(i);var g=parseInt(h.getAttribute("data-interval"),10);g?(this._config.defaultInterval=this._config.defaultInterval||this._config.interval,this._config.interval=g):this._config.interval=this._config.defaultInterval||this._config.interval;var m=a.getTransitionDurationFromElement(l);e(l).one(a.TRANSITION_END,(function(){e(h).removeClass(i+" "+o).addClass("active"),e(l).removeClass("active "+o+" "+i),r._isSliding=!1,setTimeout((function(){return e(r._element).trigger(f)}),0)})).emulateTransitionEnd(m)}else e(l).removeClass("active"),e(h).addClass("active"),this._isSliding=!1,e(this._element).trigger(f);d&&this.cycle()}},t._jQueryInterface=function(n){return this.each((function(){var i=e(this).data("bs.carousel"),o=s({},p,e(this).data());"object"==typeof n&&(o=s({},o,n));var r="string"==typeof n?n:o.slide;if(i||(i=new t(this,o),e(this).data("bs.carousel",i)),"number"==typeof n)i.to(n);else if("string"==typeof r){if("undefined"==typeof i[r])throw new TypeError('No method named "'+r+'"');i[r]()}else o.interval&&o.ride&&(i.pause(),i.cycle())}))},t._dataApiClickHandler=function(n){var i=a.getSelectorFromElement(this);if(i){var o=e(i)[0];if(o&&e(o).hasClass("carousel")){var r=s({},e(o).data(),e(this).data()),l=this.getAttribute("data-slide-to");l&&(r.interval=!1),t._jQueryInterface.call(e(o),r),l&&e(o).data("bs.carousel").to(l),n.preventDefault()}}},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}},{key:"Default",get:function(){return p}}]),t}();e(document).on("click.bs.carousel.data-api","[data-slide], [data-slide-to]",b._dataApiClickHandler),e(window).on("load.bs.carousel.data-api",(function(){for(var t=[].slice.call(document.querySelectorAll('[data-ride="carousel"]')),n=0,i=t.length;n0&&(this._selector=r,this._triggerArray.push(s))}this._parent=this._config.parent?this._getParent():null,this._config.parent||this._addAriaAndCollapsedClass(this._element,this._triggerArray),this._config.toggle&&this.toggle()}var n=t.prototype;return n.toggle=function(){e(this._element).hasClass("show")?this.hide():this.show()},n.show=function(){var n,i,o=this;if(!this._isTransitioning&&!e(this._element).hasClass("show")&&(this._parent&&0===(n=[].slice.call(this._parent.querySelectorAll(".show, .collapsing")).filter((function(t){return"string"==typeof o._config.parent?t.getAttribute("data-parent")===o._config.parent:t.classList.contains("collapse")}))).length&&(n=null),!(n&&(i=e(n).not(this._selector).data("bs.collapse"))&&i._isTransitioning))){var s=e.Event("show.bs.collapse");if(e(this._element).trigger(s),!s.isDefaultPrevented()){n&&(t._jQueryInterface.call(e(n).not(this._selector),"hide"),i||e(n).data("bs.collapse",null));var r=this._getDimension();e(this._element).removeClass("collapse").addClass("collapsing"),this._element.style[r]=0,this._triggerArray.length&&e(this._triggerArray).removeClass("collapsed").attr("aria-expanded",!0),this.setTransitioning(!0);var l="scroll"+(r[0].toUpperCase()+r.slice(1)),c=a.getTransitionDurationFromElement(this._element);e(this._element).one(a.TRANSITION_END,(function(){e(o._element).removeClass("collapsing").addClass("collapse show"),o._element.style[r]="",o.setTransitioning(!1),e(o._element).trigger("shown.bs.collapse")})).emulateTransitionEnd(c),this._element.style[r]=this._element[l]+"px"}}},n.hide=function(){var t=this;if(!this._isTransitioning&&e(this._element).hasClass("show")){var n=e.Event("hide.bs.collapse");if(e(this._element).trigger(n),!n.isDefaultPrevented()){var i=this._getDimension();this._element.style[i]=this._element.getBoundingClientRect()[i]+"px",a.reflow(this._element),e(this._element).addClass("collapsing").removeClass("collapse show");var o=this._triggerArray.length;if(o>0)for(var s=0;s0},i._getOffset=function(){var t=this,e={};return"function"==typeof this._config.offset?e.fn=function(e){return e.offsets=s({},e.offsets,t._config.offset(e.offsets,t._element)||{}),e}:e.offset=this._config.offset,e},i._getPopperConfig=function(){var t={placement:this._getPlacement(),modifiers:{offset:this._getOffset(),flip:{enabled:this._config.flip},preventOverflow:{boundariesElement:this._config.boundary}}};return"static"===this._config.display&&(t.modifiers.applyStyle={enabled:!1}),s({},t,this._config.popperConfig)},t._jQueryInterface=function(n){return this.each((function(){var i=e(this).data("bs.dropdown");if(i||(i=new t(this,"object"==typeof n?n:null),e(this).data("bs.dropdown",i)),"string"==typeof n){if("undefined"==typeof i[n])throw new TypeError('No method named "'+n+'"');i[n]()}}))},t._clearMenus=function(n){if(!n||3!==n.which&&("keyup"!==n.type||9===n.which))for(var i=[].slice.call(document.querySelectorAll('[data-toggle="dropdown"]')),o=0,s=i.length;o0&&r--,40===n.which&&rdocument.documentElement.clientHeight;i||(this._element.style.overflowY="hidden"),this._element.classList.add("modal-static");var o=a.getTransitionDurationFromElement(this._dialog);e(this._element).off(a.TRANSITION_END),e(this._element).one(a.TRANSITION_END,(function(){t._element.classList.remove("modal-static"),i||e(t._element).one(a.TRANSITION_END,(function(){t._element.style.overflowY=""})).emulateTransitionEnd(t._element,o)})).emulateTransitionEnd(o),this._element.focus()}else this.hide()},n._showElement=function(t){var n=this,i=e(this._element).hasClass("fade"),o=this._dialog?this._dialog.querySelector(".modal-body"):null;this._element.parentNode&&this._element.parentNode.nodeType===Node.ELEMENT_NODE||document.body.appendChild(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),e(this._dialog).hasClass("modal-dialog-scrollable")&&o?o.scrollTop=0:this._element.scrollTop=0,i&&a.reflow(this._element),e(this._element).addClass("show"),this._config.focus&&this._enforceFocus();var s=e.Event("shown.bs.modal",{relatedTarget:t}),r=function(){n._config.focus&&n._element.focus(),n._isTransitioning=!1,e(n._element).trigger(s)};if(i){var l=a.getTransitionDurationFromElement(this._dialog);e(this._dialog).one(a.TRANSITION_END,r).emulateTransitionEnd(l)}else r()},n._enforceFocus=function(){var t=this;e(document).off("focusin.bs.modal").on("focusin.bs.modal",(function(n){document!==n.target&&t._element!==n.target&&0===e(t._element).has(n.target).length&&t._element.focus()}))},n._setEscapeEvent=function(){var t=this;this._isShown?e(this._element).on("keydown.dismiss.bs.modal",(function(e){t._config.keyboard&&27===e.which?(e.preventDefault(),t.hide()):t._config.keyboard||27!==e.which||t._triggerBackdropTransition()})):this._isShown||e(this._element).off("keydown.dismiss.bs.modal")},n._setResizeEvent=function(){var t=this;this._isShown?e(window).on("resize.bs.modal",(function(e){return t.handleUpdate(e)})):e(window).off("resize.bs.modal")},n._hideModal=function(){var t=this;this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._showBackdrop((function(){e(document.body).removeClass("modal-open"),t._resetAdjustments(),t._resetScrollbar(),e(t._element).trigger("hidden.bs.modal")}))},n._removeBackdrop=function(){this._backdrop&&(e(this._backdrop).remove(),this._backdrop=null)},n._showBackdrop=function(t){var n=this,i=e(this._element).hasClass("fade")?"fade":"";if(this._isShown&&this._config.backdrop){if(this._backdrop=document.createElement("div"),this._backdrop.className="modal-backdrop",i&&this._backdrop.classList.add(i),e(this._backdrop).appendTo(document.body),e(this._element).on("click.dismiss.bs.modal",(function(t){n._ignoreBackdropClick?n._ignoreBackdropClick=!1:t.target===t.currentTarget&&n._triggerBackdropTransition()})),i&&a.reflow(this._backdrop),e(this._backdrop).addClass("show"),!t)return;if(!i)return void t();var o=a.getTransitionDurationFromElement(this._backdrop);e(this._backdrop).one(a.TRANSITION_END,t).emulateTransitionEnd(o)}else if(!this._isShown&&this._backdrop){e(this._backdrop).removeClass("show");var s=function(){n._removeBackdrop(),t&&t()};if(e(this._element).hasClass("fade")){var r=a.getTransitionDurationFromElement(this._backdrop);e(this._backdrop).one(a.TRANSITION_END,s).emulateTransitionEnd(r)}else s()}else t&&t()},n._adjustDialog=function(){var t=this._element.scrollHeight>document.documentElement.clientHeight;!this._isBodyOverflowing&&t&&(this._element.style.paddingLeft=this._scrollbarWidth+"px"),this._isBodyOverflowing&&!t&&(this._element.style.paddingRight=this._scrollbarWidth+"px")},n._resetAdjustments=function(){this._element.style.paddingLeft="",this._element.style.paddingRight=""},n._checkScrollbar=function(){var t=document.body.getBoundingClientRect();this._isBodyOverflowing=Math.round(t.left+t.right)
',trigger:"hover focus",title:"",delay:0,html:!1,selector:!1,placement:"top",offset:0,container:!1,fallbackPlacement:"flip",boundary:"scrollParent",sanitize:!0,sanitizeFn:null,whiteList:L,popperConfig:null},K={HIDE:"hide.bs.tooltip",HIDDEN:"hidden.bs.tooltip",SHOW:"show.bs.tooltip",SHOWN:"shown.bs.tooltip",INSERTED:"inserted.bs.tooltip",CLICK:"click.bs.tooltip",FOCUSIN:"focusin.bs.tooltip",FOCUSOUT:"focusout.bs.tooltip",MOUSEENTER:"mouseenter.bs.tooltip",MOUSELEAVE:"mouseleave.bs.tooltip"},X=function(){function t(t,e){if("undefined"==typeof n)throw new TypeError("Bootstrap's tooltips require Popper.js (https://popper.js.org/)");this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this.element=t,this.config=this._getConfig(e),this.tip=null,this._setListeners()}var i=t.prototype;return i.enable=function(){this._isEnabled=!0},i.disable=function(){this._isEnabled=!1},i.toggleEnabled=function(){this._isEnabled=!this._isEnabled},i.toggle=function(t){if(this._isEnabled)if(t){var n=this.constructor.DATA_KEY,i=e(t.currentTarget).data(n);i||(i=new this.constructor(t.currentTarget,this._getDelegateConfig()),e(t.currentTarget).data(n,i)),i._activeTrigger.click=!i._activeTrigger.click,i._isWithActiveTrigger()?i._enter(null,i):i._leave(null,i)}else{if(e(this.getTipElement()).hasClass("show"))return void this._leave(null,this);this._enter(null,this)}},i.dispose=function(){clearTimeout(this._timeout),e.removeData(this.element,this.constructor.DATA_KEY),e(this.element).off(this.constructor.EVENT_KEY),e(this.element).closest(".modal").off("hide.bs.modal",this._hideModalHandler),this.tip&&e(this.tip).remove(),this._isEnabled=null,this._timeout=null,this._hoverState=null,this._activeTrigger=null,this._popper&&this._popper.destroy(),this._popper=null,this.element=null,this.config=null,this.tip=null},i.show=function(){var t=this;if("none"===e(this.element).css("display"))throw new Error("Please use show on visible elements");var i=e.Event(this.constructor.Event.SHOW);if(this.isWithContent()&&this._isEnabled){e(this.element).trigger(i);var o=a.findShadowRoot(this.element),s=e.contains(null!==o?o:this.element.ownerDocument.documentElement,this.element);if(i.isDefaultPrevented()||!s)return;var r=this.getTipElement(),l=a.getUID(this.constructor.NAME);r.setAttribute("id",l),this.element.setAttribute("aria-describedby",l),this.setContent(),this.config.animation&&e(r).addClass("fade");var c="function"==typeof this.config.placement?this.config.placement.call(this,r,this.element):this.config.placement,h=this._getAttachment(c);this.addAttachmentClass(h);var u=this._getContainer();e(r).data(this.constructor.DATA_KEY,this),e.contains(this.element.ownerDocument.documentElement,this.tip)||e(r).appendTo(u),e(this.element).trigger(this.constructor.Event.INSERTED),this._popper=new n(this.element,r,this._getPopperConfig(h)),e(r).addClass("show"),"ontouchstart"in document.documentElement&&e(document.body).children().on("mouseover",null,e.noop);var d=function(){t.config.animation&&t._fixTransition();var n=t._hoverState;t._hoverState=null,e(t.element).trigger(t.constructor.Event.SHOWN),"out"===n&&t._leave(null,t)};if(e(this.tip).hasClass("fade")){var f=a.getTransitionDurationFromElement(this.tip);e(this.tip).one(a.TRANSITION_END,d).emulateTransitionEnd(f)}else d()}},i.hide=function(t){var n=this,i=this.getTipElement(),o=e.Event(this.constructor.Event.HIDE),s=function(){"show"!==n._hoverState&&i.parentNode&&i.parentNode.removeChild(i),n._cleanTipClass(),n.element.removeAttribute("aria-describedby"),e(n.element).trigger(n.constructor.Event.HIDDEN),null!==n._popper&&n._popper.destroy(),t&&t()};if(e(this.element).trigger(o),!o.isDefaultPrevented()){if(e(i).removeClass("show"),"ontouchstart"in document.documentElement&&e(document.body).children().off("mouseover",null,e.noop),this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1,e(this.tip).hasClass("fade")){var r=a.getTransitionDurationFromElement(i);e(i).one(a.TRANSITION_END,s).emulateTransitionEnd(r)}else s();this._hoverState=""}},i.update=function(){null!==this._popper&&this._popper.scheduleUpdate()},i.isWithContent=function(){return Boolean(this.getTitle())},i.addAttachmentClass=function(t){e(this.getTipElement()).addClass("bs-tooltip-"+t)},i.getTipElement=function(){return this.tip=this.tip||e(this.config.template)[0],this.tip},i.setContent=function(){var t=this.getTipElement();this.setElementContent(e(t.querySelectorAll(".tooltip-inner")),this.getTitle()),e(t).removeClass("fade show")},i.setElementContent=function(t,n){"object"!=typeof n||!n.nodeType&&!n.jquery?this.config.html?(this.config.sanitize&&(n=Q(n,this.config.whiteList,this.config.sanitizeFn)),t.html(n)):t.text(n):this.config.html?e(n).parent().is(t)||t.empty().append(n):t.text(e(n).text())},i.getTitle=function(){var t=this.element.getAttribute("data-original-title");return t||(t="function"==typeof this.config.title?this.config.title.call(this.element):this.config.title),t},i._getPopperConfig=function(t){var e=this;return s({},{placement:t,modifiers:{offset:this._getOffset(),flip:{behavior:this.config.fallbackPlacement},arrow:{element:".arrow"},preventOverflow:{boundariesElement:this.config.boundary}},onCreate:function(t){t.originalPlacement!==t.placement&&e._handlePopperPlacementChange(t)},onUpdate:function(t){return e._handlePopperPlacementChange(t)}},this.config.popperConfig)},i._getOffset=function(){var t=this,e={};return"function"==typeof this.config.offset?e.fn=function(e){return e.offsets=s({},e.offsets,t.config.offset(e.offsets,t.element)||{}),e}:e.offset=this.config.offset,e},i._getContainer=function(){return!1===this.config.container?document.body:a.isElement(this.config.container)?e(this.config.container):e(document).find(this.config.container)},i._getAttachment=function(t){return V[t.toUpperCase()]},i._setListeners=function(){var t=this;this.config.trigger.split(" ").forEach((function(n){if("click"===n)e(t.element).on(t.constructor.Event.CLICK,t.config.selector,(function(e){return t.toggle(e)}));else if("manual"!==n){var i="hover"===n?t.constructor.Event.MOUSEENTER:t.constructor.Event.FOCUSIN,o="hover"===n?t.constructor.Event.MOUSELEAVE:t.constructor.Event.FOCUSOUT;e(t.element).on(i,t.config.selector,(function(e){return t._enter(e)})).on(o,t.config.selector,(function(e){return t._leave(e)}))}})),this._hideModalHandler=function(){t.element&&t.hide()},e(this.element).closest(".modal").on("hide.bs.modal",this._hideModalHandler),this.config.selector?this.config=s({},this.config,{trigger:"manual",selector:""}):this._fixTitle()},i._fixTitle=function(){var t=typeof this.element.getAttribute("data-original-title");(this.element.getAttribute("title")||"string"!==t)&&(this.element.setAttribute("data-original-title",this.element.getAttribute("title")||""),this.element.setAttribute("title",""))},i._enter=function(t,n){var i=this.constructor.DATA_KEY;(n=n||e(t.currentTarget).data(i))||(n=new this.constructor(t.currentTarget,this._getDelegateConfig()),e(t.currentTarget).data(i,n)),t&&(n._activeTrigger["focusin"===t.type?"focus":"hover"]=!0),e(n.getTipElement()).hasClass("show")||"show"===n._hoverState?n._hoverState="show":(clearTimeout(n._timeout),n._hoverState="show",n.config.delay&&n.config.delay.show?n._timeout=setTimeout((function(){"show"===n._hoverState&&n.show()}),n.config.delay.show):n.show())},i._leave=function(t,n){var i=this.constructor.DATA_KEY;(n=n||e(t.currentTarget).data(i))||(n=new this.constructor(t.currentTarget,this._getDelegateConfig()),e(t.currentTarget).data(i,n)),t&&(n._activeTrigger["focusout"===t.type?"focus":"hover"]=!1),n._isWithActiveTrigger()||(clearTimeout(n._timeout),n._hoverState="out",n.config.delay&&n.config.delay.hide?n._timeout=setTimeout((function(){"out"===n._hoverState&&n.hide()}),n.config.delay.hide):n.hide())},i._isWithActiveTrigger=function(){for(var t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1},i._getConfig=function(t){var n=e(this.element).data();return Object.keys(n).forEach((function(t){-1!==M.indexOf(t)&&delete n[t]})),"number"==typeof(t=s({},this.constructor.Default,n,"object"==typeof t&&t?t:{})).delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),a.typeCheckConfig(B,t,this.constructor.DefaultType),t.sanitize&&(t.template=Q(t.template,t.whiteList,t.sanitizeFn)),t},i._getDelegateConfig=function(){var t={};if(this.config)for(var e in this.config)this.constructor.Default[e]!==this.config[e]&&(t[e]=this.config[e]);return t},i._cleanTipClass=function(){var t=e(this.getTipElement()),n=t.attr("class").match(U);null!==n&&n.length&&t.removeClass(n.join(""))},i._handlePopperPlacementChange=function(t){this.tip=t.instance.popper,this._cleanTipClass(),this.addAttachmentClass(this._getAttachment(t.placement))},i._fixTransition=function(){var t=this.getTipElement(),n=this.config.animation;null===t.getAttribute("x-placement")&&(e(t).removeClass("fade"),this.config.animation=!1,this.hide(),this.show(),this.config.animation=n)},t._jQueryInterface=function(n){return this.each((function(){var i=e(this).data("bs.tooltip"),o="object"==typeof n&&n;if((i||!/dispose|hide/.test(n))&&(i||(i=new t(this,o),e(this).data("bs.tooltip",i)),"string"==typeof n)){if("undefined"==typeof i[n])throw new TypeError('No method named "'+n+'"');i[n]()}}))},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}},{key:"Default",get:function(){return z}},{key:"NAME",get:function(){return B}},{key:"DATA_KEY",get:function(){return"bs.tooltip"}},{key:"Event",get:function(){return K}},{key:"EVENT_KEY",get:function(){return".bs.tooltip"}},{key:"DefaultType",get:function(){return W}}]),t}();e.fn[B]=X._jQueryInterface,e.fn[B].Constructor=X,e.fn[B].noConflict=function(){return e.fn[B]=H,X._jQueryInterface};var Y="popover",$=e.fn[Y],J=new RegExp("(^|\\s)bs-popover\\S+","g"),G=s({},X.Default,{placement:"right",trigger:"click",content:"",template:''}),Z=s({},X.DefaultType,{content:"(string|element|function)"}),tt={HIDE:"hide.bs.popover",HIDDEN:"hidden.bs.popover",SHOW:"show.bs.popover",SHOWN:"shown.bs.popover",INSERTED:"inserted.bs.popover",CLICK:"click.bs.popover",FOCUSIN:"focusin.bs.popover",FOCUSOUT:"focusout.bs.popover",MOUSEENTER:"mouseenter.bs.popover",MOUSELEAVE:"mouseleave.bs.popover"},et=function(t){var n,i;function s(){return t.apply(this,arguments)||this}i=t,(n=s).prototype=Object.create(i.prototype),n.prototype.constructor=n,n.__proto__=i;var r=s.prototype;return r.isWithContent=function(){return this.getTitle()||this._getContent()},r.addAttachmentClass=function(t){e(this.getTipElement()).addClass("bs-popover-"+t)},r.getTipElement=function(){return this.tip=this.tip||e(this.config.template)[0],this.tip},r.setContent=function(){var t=e(this.getTipElement());this.setElementContent(t.find(".popover-header"),this.getTitle());var n=this._getContent();"function"==typeof n&&(n=n.call(this.element)),this.setElementContent(t.find(".popover-body"),n),t.removeClass("fade show")},r._getContent=function(){return this.element.getAttribute("data-content")||this.config.content},r._cleanTipClass=function(){var t=e(this.getTipElement()),n=t.attr("class").match(J);null!==n&&n.length>0&&t.removeClass(n.join(""))},s._jQueryInterface=function(t){return this.each((function(){var n=e(this).data("bs.popover"),i="object"==typeof t?t:null;if((n||!/dispose|hide/.test(t))&&(n||(n=new s(this,i),e(this).data("bs.popover",n)),"string"==typeof t)){if("undefined"==typeof n[t])throw new TypeError('No method named "'+t+'"');n[t]()}}))},o(s,null,[{key:"VERSION",get:function(){return"4.5.2"}},{key:"Default",get:function(){return G}},{key:"NAME",get:function(){return Y}},{key:"DATA_KEY",get:function(){return"bs.popover"}},{key:"Event",get:function(){return tt}},{key:"EVENT_KEY",get:function(){return".bs.popover"}},{key:"DefaultType",get:function(){return Z}}]),s}(X);e.fn[Y]=et._jQueryInterface,e.fn[Y].Constructor=et,e.fn[Y].noConflict=function(){return e.fn[Y]=$,et._jQueryInterface};var nt="scrollspy",it=e.fn[nt],ot={offset:10,method:"auto",target:""},st={offset:"number",method:"string",target:"(string|element)"},rt=function(){function t(t,n){var i=this;this._element=t,this._scrollElement="BODY"===t.tagName?window:t,this._config=this._getConfig(n),this._selector=this._config.target+" .nav-link,"+this._config.target+" .list-group-item,"+this._config.target+" .dropdown-item",this._offsets=[],this._targets=[],this._activeTarget=null,this._scrollHeight=0,e(this._scrollElement).on("scroll.bs.scrollspy",(function(t){return i._process(t)})),this.refresh(),this._process()}var n=t.prototype;return n.refresh=function(){var t=this,n=this._scrollElement===this._scrollElement.window?"offset":"position",i="auto"===this._config.method?n:this._config.method,o="position"===i?this._getScrollTop():0;this._offsets=[],this._targets=[],this._scrollHeight=this._getScrollHeight(),[].slice.call(document.querySelectorAll(this._selector)).map((function(t){var n,s=a.getSelectorFromElement(t);if(s&&(n=document.querySelector(s)),n){var r=n.getBoundingClientRect();if(r.width||r.height)return[e(n)[i]().top+o,s]}return null})).filter((function(t){return t})).sort((function(t,e){return t[0]-e[0]})).forEach((function(e){t._offsets.push(e[0]),t._targets.push(e[1])}))},n.dispose=function(){e.removeData(this._element,"bs.scrollspy"),e(this._scrollElement).off(".bs.scrollspy"),this._element=null,this._scrollElement=null,this._config=null,this._selector=null,this._offsets=null,this._targets=null,this._activeTarget=null,this._scrollHeight=null},n._getConfig=function(t){if("string"!=typeof(t=s({},ot,"object"==typeof t&&t?t:{})).target&&a.isElement(t.target)){var n=e(t.target).attr("id");n||(n=a.getUID(nt),e(t.target).attr("id",n)),t.target="#"+n}return a.typeCheckConfig(nt,t,st),t},n._getScrollTop=function(){return this._scrollElement===window?this._scrollElement.pageYOffset:this._scrollElement.scrollTop},n._getScrollHeight=function(){return this._scrollElement.scrollHeight||Math.max(document.body.scrollHeight,document.documentElement.scrollHeight)},n._getOffsetHeight=function(){return this._scrollElement===window?window.innerHeight:this._scrollElement.getBoundingClientRect().height},n._process=function(){var t=this._getScrollTop()+this._config.offset,e=this._getScrollHeight(),n=this._config.offset+e-this._getOffsetHeight();if(this._scrollHeight!==e&&this.refresh(),t>=n){var i=this._targets[this._targets.length-1];this._activeTarget!==i&&this._activate(i)}else{if(this._activeTarget&&t0)return this._activeTarget=null,void this._clear();for(var o=this._offsets.length;o--;){this._activeTarget!==this._targets[o]&&t>=this._offsets[o]&&("undefined"==typeof this._offsets[o+1]||t li > .active":".active";i=(i=e.makeArray(e(o).find(r)))[i.length-1]}var l=e.Event("hide.bs.tab",{relatedTarget:this._element}),c=e.Event("show.bs.tab",{relatedTarget:i});if(i&&e(i).trigger(l),e(this._element).trigger(c),!c.isDefaultPrevented()&&!l.isDefaultPrevented()){s&&(n=document.querySelector(s)),this._activate(this._element,o);var h=function(){var n=e.Event("hidden.bs.tab",{relatedTarget:t._element}),o=e.Event("shown.bs.tab",{relatedTarget:i});e(i).trigger(n),e(t._element).trigger(o)};n?this._activate(n,n.parentNode,h):h()}}},n.dispose=function(){e.removeData(this._element,"bs.tab"),this._element=null},n._activate=function(t,n,i){var o=this,s=(!n||"UL"!==n.nodeName&&"OL"!==n.nodeName?e(n).children(".active"):e(n).find("> li > .active"))[0],r=i&&s&&e(s).hasClass("fade"),l=function(){return o._transitionComplete(t,s,i)};if(s&&r){var c=a.getTransitionDurationFromElement(s);e(s).removeClass("show").one(a.TRANSITION_END,l).emulateTransitionEnd(c)}else l()},n._transitionComplete=function(t,n,i){if(n){e(n).removeClass("active");var o=e(n.parentNode).find("> .dropdown-menu .active")[0];o&&e(o).removeClass("active"),"tab"===n.getAttribute("role")&&n.setAttribute("aria-selected",!1)}if(e(t).addClass("active"),"tab"===t.getAttribute("role")&&t.setAttribute("aria-selected",!0),a.reflow(t),t.classList.contains("fade")&&t.classList.add("show"),t.parentNode&&e(t.parentNode).hasClass("dropdown-menu")){var s=e(t).closest(".dropdown")[0];if(s){var r=[].slice.call(s.querySelectorAll(".dropdown-toggle"));e(r).addClass("active")}t.setAttribute("aria-expanded",!0)}i&&i()},t._jQueryInterface=function(n){return this.each((function(){var i=e(this),o=i.data("bs.tab");if(o||(o=new t(this),i.data("bs.tab",o)),"string"==typeof n){if("undefined"==typeof o[n])throw new TypeError('No method named "'+n+'"');o[n]()}}))},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}}]),t}();e(document).on("click.bs.tab.data-api",'[data-toggle="tab"], [data-toggle="pill"], [data-toggle="list"]',(function(t){t.preventDefault(),lt._jQueryInterface.call(e(this),"show")})),e.fn.tab=lt._jQueryInterface,e.fn.tab.Constructor=lt,e.fn.tab.noConflict=function(){return e.fn.tab=at,lt._jQueryInterface};var ct=e.fn.toast,ht={animation:"boolean",autohide:"boolean",delay:"number"},ut={animation:!0,autohide:!0,delay:500},dt=function(){function t(t,e){this._element=t,this._config=this._getConfig(e),this._timeout=null,this._setListeners()}var n=t.prototype;return n.show=function(){var t=this,n=e.Event("show.bs.toast");if(e(this._element).trigger(n),!n.isDefaultPrevented()){this._clearTimeout(),this._config.animation&&this._element.classList.add("fade");var i=function(){t._element.classList.remove("showing"),t._element.classList.add("show"),e(t._element).trigger("shown.bs.toast"),t._config.autohide&&(t._timeout=setTimeout((function(){t.hide()}),t._config.delay))};if(this._element.classList.remove("hide"),a.reflow(this._element),this._element.classList.add("showing"),this._config.animation){var o=a.getTransitionDurationFromElement(this._element);e(this._element).one(a.TRANSITION_END,i).emulateTransitionEnd(o)}else i()}},n.hide=function(){if(this._element.classList.contains("show")){var t=e.Event("hide.bs.toast");e(this._element).trigger(t),t.isDefaultPrevented()||this._close()}},n.dispose=function(){this._clearTimeout(),this._element.classList.contains("show")&&this._element.classList.remove("show"),e(this._element).off("click.dismiss.bs.toast"),e.removeData(this._element,"bs.toast"),this._element=null,this._config=null},n._getConfig=function(t){return t=s({},ut,e(this._element).data(),"object"==typeof t&&t?t:{}),a.typeCheckConfig("toast",t,this.constructor.DefaultType),t},n._setListeners=function(){var t=this;e(this._element).on("click.dismiss.bs.toast",'[data-dismiss="toast"]',(function(){return t.hide()}))},n._close=function(){var t=this,n=function(){t._element.classList.add("hide"),e(t._element).trigger("hidden.bs.toast")};if(this._element.classList.remove("show"),this._config.animation){var i=a.getTransitionDurationFromElement(this._element);e(this._element).one(a.TRANSITION_END,n).emulateTransitionEnd(i)}else n()},n._clearTimeout=function(){clearTimeout(this._timeout),this._timeout=null},t._jQueryInterface=function(n){return this.each((function(){var i=e(this),o=i.data("bs.toast");if(o||(o=new t(this,"object"==typeof n&&n),i.data("bs.toast",o)),"string"==typeof n){if("undefined"==typeof o[n])throw new TypeError('No method named "'+n+'"');o[n](this)}}))},o(t,null,[{key:"VERSION",get:function(){return"4.5.2"}},{key:"DefaultType",get:function(){return ht}},{key:"Default",get:function(){return ut}}]),t}();e.fn.toast=dt._jQueryInterface,e.fn.toast.Constructor=dt,e.fn.toast.noConflict=function(){return e.fn.toast=ct,dt._jQueryInterface},t.Alert=h,t.Button=d,t.Carousel=b,t.Collapse=C,t.Dropdown=I,t.Modal=P,t.Popover=et,t.Scrollspy=rt,t.Tab=lt,t.Toast=dt,t.Tooltip=X,t.Util=a,Object.defineProperty(t,"__esModule",{value:!0})})); -//# sourceMappingURL=bootstrap.min.js.map \ No newline at end of file diff --git a/test/settings.test.js b/test/settings.test.js new file mode 100644 index 0000000..b650b94 --- /dev/null +++ b/test/settings.test.js @@ -0,0 +1,60 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_SETTINGS, + defaultRules, + effectiveRules, + exportBundle, + mergeSettings, + parseImport +} from '../src/lib/settings.js'; +import { DEFAULT_MEDIA_PATTERNS, DEFAULT_RULES, TRACKING_PARAMS } from '../src/lib/url.js'; + +test('mergeSettings fills defaults and merges normalization', () => { + const s = mergeSettings({ staleDays: 3, normalization: { stripWww: false } }); + assert.equal(s.staleDays, 3); + assert.equal(s.normalization.stripWww, false); + assert.equal(s.normalization.ignoreFragment, true); + assert.deepEqual(s.normalization.trackingParams, TRACKING_PARAMS); + assert.deepEqual(s.mediaPatterns, DEFAULT_MEDIA_PATTERNS); + assert.deepEqual(mergeSettings(undefined), { ...DEFAULT_SETTINGS, normalization: mergeSettings().normalization }); +}); + +test('defaultRules are enabled copies of the built-ins', () => { + const rules = defaultRules(); + assert.equal(rules.length, DEFAULT_RULES.length); + assert.ok(rules.every((r) => r.enabled && r.builtin)); + rules[0].name = 'changed'; + assert.notEqual(DEFAULT_RULES[0].name, 'changed'); +}); + +test('effectiveRules appends remote rules unless a local rule has the same name', () => { + const local = [{ name: 'A', match: 'a', key: 'a' }]; + const remote = [{ name: 'A', match: 'x', key: 'x' }, { name: 'B', match: 'b', key: 'b' }]; + const out = effectiveRules(local, remote); + assert.deepEqual(out.map((r) => r.name), ['A', 'B']); + assert.equal(out[0].match, 'a'); + assert.equal(out[1].remote, true); + assert.deepEqual(effectiveRules(undefined, undefined), []); +}); + +test('parseImport accepts a bundle, a {rules} object, or a bare array', () => { + const bundle = exportBundle({ settings: { staleDays: 9 }, rules: [{ name: 'R', match: 'm', key: 'k' }] }); + assert.equal(bundle.app, 'organize-tabs'); + const parsed = parseImport(JSON.stringify(bundle)); + assert.equal(parsed.settings.staleDays, 9); + assert.deepEqual(parsed.rules, [{ name: 'R', match: 'm', key: 'k', enabled: true }]); + assert.deepEqual(parseImport('{"rules":[{"match":"m","key":"k","enabled":false}]}').rules, [ + { name: 'Rule', match: 'm', key: 'k', enabled: false } + ]); + assert.equal(parseImport('[{"match":"m","key":"k"}]').rules.length, 1); + assert.equal(parseImport('{"rules":[]}').rules.length, 0); +}); + +test('parseImport rejects bad input with a readable message', () => { + assert.throws(() => parseImport('not json'), /Not valid JSON/); + assert.throws(() => parseImport('42'), /Expected a JSON object/); + assert.throws(() => parseImport('{"rules": "x"}'), /must be an array/); + assert.throws(() => parseImport('{"rules": [{"match": 1}]}'), /Rule 1 needs/); + assert.throws(() => parseImport('{"foo": 1}'), /Nothing to import/); +}); diff --git a/test/url.test.js b/test/url.test.js new file mode 100644 index 0000000..dc9c004 --- /dev/null +++ b/test/url.test.js @@ -0,0 +1,313 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + DEFAULT_RULES, + canonicalize, + compareTabs, + compileRules, + findDuplicates, + groupTitleForHost, + hostOf, + isStale, + normalizeUrl, + scopesFor, + sectionOf +} from '../src/lib/url.js'; + +const { compiled, errors } = compileRules(DEFAULT_RULES); + +test('default rules all compile', () => { + assert.equal(errors.length, 0); + assert.equal(compiled.length, DEFAULT_RULES.length); +}); + +test('normalizeUrl strips fragment, www, tracking params, trailing slash', () => { + assert.equal( + normalizeUrl('http://www.Example.com/a/b/?utm_source=x&b=2&a=1#frag'), + 'https://example.com/a/b?a=1&b=2' + ); + assert.equal(normalizeUrl('https://example.com/'), 'https://example.com/'); + assert.equal(normalizeUrl('https://example.com'), 'https://example.com/'); +}); + +test('normalizeUrl respects options', () => { + assert.equal( + normalizeUrl('http://www.example.com/a#x', { + ignoreScheme: false, + stripWww: false, + ignoreFragment: false + }), + 'http://www.example.com/a#x' + ); +}); + +test('normalizeUrl leaves non-web URLs alone apart from the fragment', () => { + assert.equal(normalizeUrl('chrome://extensions/#foo'), 'chrome://extensions/'); + assert.equal(normalizeUrl('not a url'), 'not a url'); +}); + +test('hostOf and sectionOf', () => { + assert.equal(hostOf('https://www.GitHub.com/dataro/x'), 'github.com'); + assert.equal(sectionOf('https://github.com/dataro/x/pull/1'), 'github.com/dataro'); + assert.equal(sectionOf('https://github.com/'), 'github.com'); + assert.equal(hostOf('chrome://newtab/'), 'newtab'); + assert.equal(hostOf('about:blank'), null); +}); + +const same = (a, b) => { + assert.equal(canonicalize(a, compiled).key, canonicalize(b, compiled).key, `${a} vs ${b}`); +}; +const different = (a, b) => { + assert.notEqual(canonicalize(a, compiled).key, canonicalize(b, compiled).key, `${a} vs ${b}`); +}; + +test('GitHub PR subpages collapse to one key', () => { + same( + 'https://github.com/dataro/app/pull/123', + 'https://github.com/dataro/app/pull/123/files' + ); + same( + 'https://github.com/dataro/app/pull/123/commits/abc', + 'https://github.com/dataro/app/pull/123#discussion_r1' + ); + different( + 'https://github.com/dataro/app/pull/123', + 'https://github.com/dataro/app/pull/124' + ); + different( + 'https://github.com/dataro/app/pull/123', + 'https://github.com/dataro/app/issues/123' + ); + assert.equal( + canonicalize('https://github.com/dataro/app/pull/123/files', compiled).rule, + 'GitHub pull request' + ); +}); + +test('Bitbucket PR subpages collapse', () => { + same( + 'https://bitbucket.org/dataro/app/pull-requests/55', + 'https://bitbucket.org/dataro/app/pull-requests/55/diff' + ); + same( + 'https://bitbucket.org/dataro/app/pull-requests/55/overview', + 'https://bitbucket.org/dataro/app/pull-requests/55/commits' + ); +}); + +test('Jira issue forms collapse', () => { + same( + 'https://dataro.atlassian.net/browse/ENG-123', + 'https://dataro.atlassian.net/jira/software/c/projects/ENG/boards/1?selectedIssue=ENG-123' + ); + same( + 'https://dataro.atlassian.net/browse/ENG-123?focusedCommentId=1', + 'https://dataro.atlassian.net/browse/ENG-123' + ); + different( + 'https://dataro.atlassian.net/browse/ENG-123', + 'https://dataro.atlassian.net/browse/ENG-1234' + ); +}); + +test('LinkedIn profile subpages collapse', () => { + same( + 'https://www.linkedin.com/in/someone/', + 'https://www.linkedin.com/in/someone/details/experience/' + ); + same( + 'https://linkedin.com/in/someone?trk=x', + 'https://www.linkedin.com/in/someone/recent-activity/all/' + ); + different('https://www.linkedin.com/in/someone/', 'https://www.linkedin.com/in/other/'); +}); + +test('YouTube forms collapse', () => { + same( + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=42s', + 'https://youtu.be/dQw4w9WgXcQ?si=abc' + ); + same( + 'https://www.youtube.com/watch?list=PL1&v=dQw4w9WgXcQ', + 'https://m.youtube.com/watch?v=dQw4w9WgXcQ' + ); +}); + +test('Google Docs edit vs view collapse', () => { + same( + 'https://docs.google.com/document/d/abc123/edit#heading=h.1', + 'https://docs.google.com/document/d/abc123/view' + ); + same( + 'https://docs.google.com/spreadsheets/d/abc123/edit#gid=0', + 'https://docs.google.com/spreadsheets/d/abc123/edit#gid=999' + ); +}); + +test('Notion page ids collapse regardless of slug', () => { + same( + 'https://www.notion.so/dataro/Some-Title-f0c129ab6a8342c2ba1c766719d1d73e', + 'https://www.notion.so/f0c129ab6a8342c2ba1c766719d1d73e?pvs=4' + ); + same( + 'https://app.notion.com/p/f0c129ab6a8342c2ba1c766719d1d73e', + 'https://app.notion.com/p/navigation-f0c129ab6a8342c2ba1c766719d1d73e' + ); +}); + +test('HubSpot, Slack, Figma, Amazon, Stack Overflow, Reddit, X', () => { + same( + 'https://app.hubspot.com/contacts/123/record/0-2/456/', + 'https://app.hubspot.com/contacts/123/record/0-2/456/?interaction=note' + ); + same( + 'https://app.hubspot.com/contacts/123/company/456', + 'https://app.hubspot.com/contacts/123/record/company/456' + ); + same( + 'https://app.slack.com/client/T1/C1/thread/C1-1.2', + 'https://app.slack.com/client/T1/C1/thread/C1-1.2?x=1' + ); + different('https://app.slack.com/client/T1/C1', 'https://app.slack.com/client/T1/C1/thread/C1-1.2'); + same('https://www.figma.com/file/AbC/x?node-id=1', 'https://www.figma.com/design/AbC/y'); + same( + 'https://www.amazon.com/Some-Product/dp/B000000001/ref=sr_1', + 'https://www.amazon.com/gp/product/B000000001' + ); + same('https://stackoverflow.com/questions/123/how', 'https://stackoverflow.com/q/123'); + same( + 'https://www.reddit.com/r/foo/comments/abc12/title/', + 'https://old.reddit.com/r/foo/comments/abc12/' + ); + same('https://twitter.com/a/status/1', 'https://x.com/b/status/1'); +}); + +test('plain URLs fall back to normalization', () => { + const c = canonicalize('https://www.example.com/x?utm_source=a#y', compiled); + assert.equal(c.rule, null); + assert.equal(c.key, 'https://example.com/x'); +}); + +test('compileRules reports bad regexes without throwing', () => { + const r = compileRules([{ name: 'bad', match: '(', key: 'x' }, { match: 'ok', key: 'k' }]); + assert.equal(r.compiled.length, 1); + assert.equal(r.errors.length, 1); + assert.equal(r.errors[0].rule.name, 'bad'); +}); + +test('custom rule with named groups', () => { + const r = compileRules([ + { name: 'ticket', match: '^https://t\\.example/(?\\d+)', key: 'ticket:${id}' } + ]); + assert.equal(canonicalize('https://t.example/42/x', r.compiled).key, 'ticket:42'); +}); + +test('findDuplicates keeps pinned, then active, then most recent', () => { + const tabs = [ + { id: 1, url: 'https://github.com/o/r/pull/1', lastAccessed: 100, windowId: 1, index: 0 }, + { id: 2, url: 'https://github.com/o/r/pull/1/files', lastAccessed: 300, windowId: 1, index: 1 }, + { id: 3, url: 'https://github.com/o/r/pull/1/commits', lastAccessed: 200, windowId: 2, index: 0 }, + { id: 4, url: 'https://github.com/o/r/pull/2', lastAccessed: 200, windowId: 2, index: 1 }, + { id: 5, url: 'chrome://newtab/', windowId: 2, index: 2 }, + { id: 6, url: 'chrome://newtab/', windowId: 2, index: 3 }, + { id: 7, url: 'https://github.com/o/r/pull/1', incognito: true, windowId: 3, index: 0 } + ]; + let sets = findDuplicates(tabs, compiled, {}, 'recent'); + assert.equal(sets.length, 1); + assert.equal(sets[0].keep.id, 2); + assert.deepEqual(sets[0].close.map((t) => t.id).sort(), [1, 3]); + + tabs[0].pinned = true; + sets = findDuplicates(tabs, compiled, {}, 'recent'); + assert.equal(sets[0].keep.id, 1); + + tabs[0].pinned = false; + tabs[2].active = true; + sets = findDuplicates(tabs, compiled, {}, 'recent'); + assert.equal(sets[0].keep.id, 3); + + tabs[2].active = false; + sets = findDuplicates(tabs, compiled, {}, 'first'); + assert.equal(sets[0].keep.id, 1); + sets = findDuplicates(tabs, compiled, {}, 'deepest'); + assert.equal(sets[0].keep.id, 3); +}); + +test('scopesFor offers domain, section and entity', () => { + const scopes = scopesFor('https://github.com/dataro/app/pull/1/files', compiled); + assert.deepEqual(scopes.map((s) => s.id), ['domain', 'section', 'entity']); + assert.equal(scopes[0].label, 'github.com'); + assert.equal(scopes[1].label, 'github.com/dataro'); + assert.equal(scopes[2].label, 'GitHub pull requests'); + assert.ok(scopes[2].test('https://github.com/other/repo/pull/9')); + assert.ok(!scopes[2].test('https://github.com/other/repo/issues/9')); + assert.ok(scopes[1].test('https://github.com/dataro/other')); + assert.ok(!scopes[1].test('https://github.com/other/x')); + + const plain = scopesFor('https://example.com/', compiled); + assert.deepEqual(plain.map((s) => s.id), ['domain']); + assert.deepEqual(scopesFor('chrome://newtab/', compiled).map((s) => s.id), ['domain']); +}); + +test('compareTabs clusters subdomains and ignores scheme/www', () => { + const tabs = [ + { url: 'https://mail.google.com/x' }, + { url: 'https://www.github.com/b' }, + { url: 'http://docs.google.com/y' }, + { url: 'https://github.com/a' }, + { url: 'chrome://newtab/' } + ]; + const urls = [...tabs].sort(compareTabs).map((t) => t.url); + assert.deepEqual(urls, [ + 'https://github.com/a', + 'https://www.github.com/b', + 'http://docs.google.com/y', + 'https://mail.google.com/x', + 'chrome://newtab/' + ]); +}); + +test('isStale', () => { + const now = 10 * 86400000; + assert.ok(isStale({ lastAccessed: 1 }, 7, now)); + assert.ok(!isStale({ lastAccessed: now - 86400000 }, 7, now)); + assert.ok(!isStale({}, 7, now)); + assert.ok(isStale({ active: false }, 0, now)); + assert.ok(!isStale({ active: true }, 0, now)); +}); + +test('groupTitleForHost', () => { + assert.equal(groupTitleForHost('github.com'), 'github'); + assert.equal(groupTitleForHost('dataro.atlassian.net'), 'atlassian'); + assert.equal(groupTitleForHost('bbc.co.uk'), 'bbc'); + assert.equal(groupTitleForHost('news.bbc.co.uk'), 'bbc'); + assert.equal(groupTitleForHost('localhost'), 'localhost'); +}); + +test('media patterns recognise meetings and players, not lobbies', async () => { + const { DEFAULT_MEDIA_PATTERNS, compileMediaPatterns, isMediaUrl, isMediaTab } = await import('../src/lib/url.js'); + const media = compileMediaPatterns(DEFAULT_MEDIA_PATTERNS); + assert.equal(media.length, DEFAULT_MEDIA_PATTERNS.length); + for (const url of [ + 'https://meet.google.com/abc-defg-hij?authuser=0', + 'https://us02web.zoom.us/j/123456789', + 'https://teams.microsoft.com/l/meetup-join/19%3ameeting', + 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'https://open.spotify.com/track/abc', + 'https://www.twitch.tv/somebody' + ]) { + assert.ok(isMediaUrl(url, media), url); + } + for (const url of [ + 'https://meet.google.com/', + 'https://meet.google.com/landing', + 'https://zoom.us/pricing', + 'https://www.youtube.com/', + 'https://github.com/x/y/pull/1' + ]) { + assert.ok(!isMediaUrl(url, media), url); + } + assert.ok(isMediaTab({ url: 'https://example.com/', audible: true }, media)); + assert.ok(!isMediaTab({ url: 'https://example.com/', audible: false }, media)); + assert.equal(compileMediaPatterns(['(', 'ok']).length, 1); +});