├── .gitignore ├── 128.png ├── 16.png ├── 32.png ├── 48.png ├── LICENSE ├── README.md ├── background.js ├── bootstrap-icons.css ├── bootstrap.bundle.min.js ├── bootstrap.min.css ├── config-tab.js ├── cookies-tab.js ├── fonts ├── bootstrap-icons.woff └── bootstrap-icons.woff2 ├── import-tab.js ├── manifest.json ├── options.html ├── options.js ├── personas-tab.js ├── popup.js ├── snapshots-tab.js └── utils.js /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArchiveBox/archivebox-browser-extension/4154bd105cc09f5fa54e2ba783e10d7065fd1436/128.png -------------------------------------------------------------------------------- /16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArchiveBox/archivebox-browser-extension/4154bd105cc09f5fa54e2ba783e10d7065fd1436/16.png -------------------------------------------------------------------------------- /32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArchiveBox/archivebox-browser-extension/4154bd105cc09f5fa54e2ba783e10d7065fd1436/32.png -------------------------------------------------------------------------------- /48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArchiveBox/archivebox-browser-extension/4154bd105cc09f5fa54e2ba783e10d7065fd1436/48.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Nick Sweeting 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🗃 ArchiveBox Browser Extension 2 | 3 | This is a browser extension that lets you send individual browser tabs or all URLs matching certain patterns to your [ArchiveBox](https://github.com/ArchiveBox/ArchiveBox) instance for offline preservation. This has a couple of benefits: 4 | 5 | - Own your data: save the web content that matters to you most, protect against link rot 6 | - Protect your data: save offline copies of pages in common, durable formats that will last for generations 7 | - Use your data: collect and tag important bookmarks, full-text search through your browsing history, automatically push captured data into other systems using ArchiveBox's APIs 8 | 9 | ## Get the Extension 10 | 11 | - Chrome / Brave / Edge / Other Chromium-based browsers 12 | - Firefox / Waterfox / Tor Browser / Other Firefox-based browsers 13 | - Safari *(not yet supported, use [Save-to-ArchiveBox Shortcut](https://www.icloud.com/shortcuts/0d3a526e7d524447aa8c1bd63ac49014) instead)* 14 | 15 | ![configuring-server](https://github.com/user-attachments/assets/308c4462-ca09-434f-89a6-3f6bac404be2) 16 | ![url-submission](https://github.com/user-attachments/assets/cfc8f670-562a-4c17-a533-4b1b0560c5c8) 17 | ![admin-ui](https://github.com/user-attachments/assets/97d90d4c-d0f3-4bc1-b7ef-1c9e410c576f) 18 | 19 | image 20 | image 21 | 22 | ![image](https://github.com/user-attachments/assets/2977d572-9086-4ea7-a4a2-2726e762a125) 23 | ![image](https://github.com/user-attachments/assets/bb2f2bde-5c40-48e4-9499-1fada83425cf) 24 | image 25 | 26 | #### Recent Changes 27 | 28 | - [x] update to manifest v3 to re-submit to Chrome web store 29 | - [x] added an admin view where you can see a list of all the URLs you've collected so far 30 | - [x] added ability to search admin view by url, timestamp, uuid, tags 31 | - [x] added the ability to export filtered URLs list from history as CSV and JSON 32 | - [x] added the ability to import URLs from chrome history / bookmarks by daterange or filter query 33 | - [x] add the ability to edit extension config options, allowlist/denylist, etc. from options.html 34 | - [x] add the ability to test connection to ArchiveBox server 35 | 36 | 37 | 38 | 39 | ## Setup 40 | 41 | 1. Set up an [ArchiveBox](https://github.com/ArchiveBox/ArchiveBox#quickstart) server and make sure it's accessible to the machine you're browsing on 42 | 2. Configure your ArchiveBox server to allow URL submissions without requiring login ([more info here...](https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration#public_index--public_snapshots--public_add_view)) 43 | *`>= v0.8.5`: users of the new BETA releases can use an API key generated at `/admin/api/apitoken/` instead.* 44 | *Alternatively: if you stay signed in to your ArchiveBox instance in the same browser, it will share your login credentials.* 45 | ```bash 46 | archivebox config --set PUBLIC_ADD_VIEW=True 47 | # (make sure to restart the server after if you apply this change) 48 | ``` 49 | Screenshot of ArchiveBox CLI configuring PUBLIC_ADD_VIEW=True 50 | 4. Configure the extension to point to your ArchiveBox server's base URL (e.g. `http://localhost:8000`, `https://archivebox.example.com`, etc.) 51 | Screenshot of extension config area: example with localhostScreenshot of extension config area: example with demo 52 | 5. ✅ *Test it out by right-clicking on any page and selecting `Save to ArchiveBox`, or by clicking the extension icon in the menubar.* 53 | Screenshot of right-clicking to add a page to ArchiveBox using extensionScreenshot of ArchiveBox server with added URL 54 | 55 | --- 56 | 57 | ## Development 58 | 59 | *✨ Originally contributed by [TJ Horner (@tjhorner)](https://github.com/tjhorner), now maintained by [@benmuth](https://github.com/benmuth) and the [ArchiveBox](https://github.com/ArchiveBox) team.* 60 | 61 | If you wish to contribute to (or just build for yourself) this extension, you will need to download and install [Node.js](https://nodejs.org/en/). 62 | 63 | ```bash 64 | git clone https://github.com/ArchiveBox/archivebox-browser-extension 65 | cd archivebox-browser-extension/ 66 | 67 | # There is no build step with v2.1.3 and above, the project uses vanilla ESM JS. 68 | # Simply load the entire directory as an unpacked extension in Chrome 69 | ``` 70 | 71 | Load the root repo folder into Chrome or Firefox using their [Load Unpacked Extension](https://developer.chrome.com/docs/extensions/get-started/tutorial/hello-world#load-unpacked) UI. 72 | 73 | Please open an issue to discuss any proposed changes *before* starting work on any PRs. 74 | 75 | ## Changelog 76 | 77 | - 2025-03 New Manifest v3 [Extension v2.1.3](https://github.com/ArchiveBox/archivebox-browser-extension/releases/tag/v2.1.3) Released 78 | - 2024-11 Development [started](https://github.com/ArchiveBox/archivebox-browser-extension/pull/31) on v2 extension with more advanced UI and tagging options 79 | - 2024-01 Extension repo moved from `tjhorner/archivebox-exporter` to `Archivebox/archivebox-browser-extension` 80 | - 2021-09 Extension offically supported by ArchiveBox v0.6.2, no longer needed to run `:dev` branch 81 | - 2021-07 Initial extension [published](https://github.com/ArchiveBox/ArchiveBox/issues/577#issuecomment-872915877) on Chrome and Mozilla web stores 82 | - 2021-06 [@tjhorner](https://github.com/tjhorner) [Created](https://github.com/ArchiveBox/ArchiveBox/issues/577) the initial `archivebox-exporter` extension 83 | 84 | --- 85 | 86 | ## Alternative Extensions for Archiving 87 | 88 | Other browser extensions that also do web archiving which may be a better fit if ArchiveBox doesn't suit your needs. 89 | 90 | - [ArchiveWeb.page](https://webrecorder.net/archivewebpage) (super high fidelity archiving extension by Webrecorder) 91 | - [SingleFile](https://github.com/gildas-lormeau/SingleFile) (a great extension for saving pages into a single `.html` file, built-in to ArchiveBox already) 92 | - [Hypothesis](https://web.hypothes.is/start/) (extension focused on annotating, but also supports archiving) 93 | - [Memex](https://memex.garden/) (another project focused on annotating that supports archiving) 94 | - [Save Page WE](https://addons.mozilla.org/en-US/firefox/addon/save-page-we/) (a Firefox extension that also saves webpages as a single HTML file) 95 | 96 | ## Other ArchiveBox Helper Projects 97 | 98 | Other projects that help with ingest URLs into ArchiveBox from various sources. 99 | 100 | - https://github.com/layderv/archivefox (user-contributed extension for Firefox) 101 | - https://github.com/Gertje823/ArchiveboxTelegramBot (Telegram Bot to send URLs to ArchiveBox) 102 | - https://github.com/TheCakeIsNaOH/xbs-to-archivebox (Download your bookmarks from xBrowserSync, filter them, and save them into ArchiveBox) 103 | - https://github.com/emschu/archivebox-quick-add (golang utility to add links to ArchiveBox) 104 | - https://github.com/FracturedCode/archivebox-reddit (automatically back up saved Reddit comments, posts, etc. to ArchiveBox) 105 | - https://github.com/dbeley/reddit_export_userdata (older Python utility to archive reddit content to ArchiveBox) 106 | - https://github.com/jess-sol/reddit-exporter (export reddit data to ArchiveBox) 107 | - https://github.com/jonesd/archivebox-pinboard-tranformer (export links from pinboard to ArchiveBox) 108 | - https://github.com/agg23/archivebox-url-forwarder (older WebExtension to forward URLs to archivebox) 109 | 110 | --- 111 | 112 | ## License 113 | 114 | MIT License 115 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | import { Snapshot, addToArchiveBox } from "./utils.js"; 2 | 3 | // Checks if URL should be auto-archived based on regex patterns and configuration settings. 4 | async function shouldAutoArchive(url) { 5 | try { 6 | console.debug(`[Auto-Archive Debug] Checking URL: ${url}`); 7 | 8 | const { enable_auto_archive=false, match_urls=[], exclude_urls=[] } = await chrome.storage.local.get([ 9 | 'enable_auto_archive', 10 | 'match_urls', 11 | 'exclude_urls', 12 | ]); 13 | 14 | console.debug(`[Auto-Archive Debug] Settings: enable_auto_archive=${enable_auto_archive}, match_urls="${match_urls}", exclude_urls="${exclude_urls}"`); 15 | 16 | if (!enable_auto_archive || !match_urls || match_urls.trim() === '') { 17 | console.debug('[Auto-Archive Debug] Auto-archiving disabled or match pattern empty'); 18 | return false; 19 | } 20 | 21 | const matchPattern = new RegExp(match_urls); 22 | const matches = matchPattern.test(url); 23 | console.debug(`[Auto-Archive Debug] URL match test: ${matches} (pattern: ${matchPattern})`); 24 | 25 | if (!matches) { 26 | return false; 27 | } 28 | 29 | if (exclude_urls.trim()) { 30 | try { 31 | const excludePattern = new RegExp(exclude_urls); 32 | const excluded = excludePattern.test(url); 33 | console.debug(`[Auto-Archive Debug] URL exclude test: ${excluded} (pattern: ${excludePattern})`); 34 | 35 | if (excluded) { 36 | return false; 37 | } 38 | } catch (error) { 39 | console.error('Invalid exclude pattern:', error); 40 | } 41 | } 42 | 43 | console.debug(`[Auto-Archive Debug] URL ${url} should be archived: TRUE`); 44 | return true; 45 | } catch (error) { 46 | console.error('Error checking auto-archive patterns:', error); 47 | return false; 48 | } 49 | } 50 | 51 | // Archives the specified tab. Meant to be used as a listener for tab updates. 52 | async function autoArchive(tabId, changeInfo, tab) { 53 | console.debug(`[Auto-Archive Debug] Tab updated - tabId: ${tabId}, status: ${changeInfo.status}, url: ${tab?.url}`); 54 | 55 | // Only process when the page has completed loading 56 | if (changeInfo.status === 'complete' && tab.url) { 57 | console.debug(`[Auto-Archive Debug] Tab load complete, checking if URL should be archived: ${tab.url}`); 58 | 59 | // Check if URL is already archived locally 60 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 61 | const isAlreadyArchived = snapshots.some(s => s.url === tab.url); 62 | 63 | if (isAlreadyArchived) { 64 | console.debug(`[Auto-Archive Debug] URL already archived, skipping: ${tab.url}`); 65 | return; 66 | } 67 | 68 | const shouldArchive = await shouldAutoArchive(tab.url); 69 | console.debug(`[Auto-Archive Debug] shouldAutoArchive result: ${shouldArchive}`); 70 | 71 | if (shouldArchive) { 72 | console.log('Auto-archiving URL:', tab.url); 73 | 74 | const snapshot = new Snapshot( 75 | tab.url, 76 | ['auto-archived'], 77 | tab.title, 78 | tab.favIconUrl, 79 | ); 80 | 81 | console.debug('[Auto-Archive Debug] Created new snapshot, saving to storage'); 82 | snapshots.push(snapshot); 83 | await chrome.storage.local.set({ entries: snapshots }); 84 | console.debug('[Auto-Archive Debug] Snapshot saved to local storage'); 85 | 86 | try { 87 | console.debug(`[Auto-Archive Debug] Calling addToArchiveBox with URL: ${snapshot.url}, tags: ${snapshot.tags.join(',')}`); 88 | await addToArchiveBox([snapshot.url], snapshot.tags); 89 | console.log(`Automatically archived ${snapshot.url}`); 90 | } catch (error) { 91 | console.error(`Failed to automatically archive ${snapshot.url}: ${error.message}`); 92 | } 93 | } 94 | } 95 | } 96 | 97 | // Checks if we should be auto-archiving, and manages the listener accordingly. If the user has 98 | // given the required permissions and enabled it through the UI, then we'll listen for tab updates 99 | // and attempt to automatically archive the desired URLs. 100 | async function configureAutoArchiving() { 101 | console.debug('[Auto-Archive Debug] Setting up auto-archiving...'); 102 | 103 | const hasPermission = await chrome.permissions.contains({ permissions: ['tabs'] }); 104 | console.debug(`[Auto-Archive Debug] Has tabs permission: ${hasPermission}`); 105 | 106 | if (!hasPermission) { 107 | console.log('Tabs permission not granted, auto-archiving disabled'); 108 | return; 109 | } 110 | 111 | const { enable_auto_archive=false } = await chrome.storage.local.get(['enable_auto_archive']); 112 | console.debug(`[Auto-Archive Debug] enable_auto_archive setting: ${enable_auto_archive}`); 113 | 114 | const hasListener = chrome.tabs.onUpdated.hasListener(autoArchive) 115 | if (enable_auto_archive) { 116 | if (!hasListener) { 117 | chrome.tabs.onUpdated.addListener(autoArchive); 118 | } 119 | console.log('Auto-archiving enabled'); 120 | } else { 121 | if (hasListener) { 122 | chrome.tabs.onUpdated.removeListener(autoArchive); 123 | } 124 | console.log('Auto-archiving disabled'); 125 | } 126 | } 127 | 128 | // Initialize auto-archiving setup on extension load 129 | configureAutoArchiving(); 130 | 131 | // Listen for changes to the auto-archive setting 132 | chrome.storage.onChanged.addListener((changes, area) => { 133 | if (area === 'local' && changes.enable_auto_archive) { 134 | configureAutoArchiving(); 135 | } 136 | }); 137 | 138 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 139 | if (message.type === 'archivebox_add') { 140 | try { 141 | const { urls = [], tags=[] } = JSON.parse(message.body); 142 | 143 | addToArchiveBox(urls, tags) 144 | .then(() => { 145 | console.log(`Successfully archived ${urls}`); 146 | sendResponse({ok: true}); 147 | } 148 | ) 149 | .catch((error) => sendResponse({ok: false, errorMessage: error.message})); 150 | } catch (error) { 151 | console.error(`Failed to parse archivebox_add message, no URLs sent to ArchiveBox server: ${error.message}`); 152 | sendResponse({ok: false, errorMessage: error.message}); 153 | return true; 154 | } 155 | } 156 | 157 | return true; 158 | }); 159 | 160 | chrome.runtime.onMessage.addListener(async (message) => { 161 | const options_url = chrome.runtime.getURL('options.html') + `?search=${message.id}`; 162 | console.log('i ArchiveBox Collector showing options.html', options_url); 163 | if (message.action === 'openOptionsPage') { 164 | await chrome.tabs.create({ url: options_url }); 165 | } 166 | }); 167 | 168 | chrome.runtime.onInstalled.addListener(function () { 169 | chrome.contextMenus.removeAll(); 170 | chrome.contextMenus.create({ 171 | id: 'save_to_archivebox_ctxmenu', 172 | title: 'Save to ArchiveBox', 173 | }); 174 | }); 175 | 176 | // Context menu button 177 | chrome.contextMenus.onClicked.addListener((item, tab) => 178 | chrome.scripting.executeScript({ 179 | target: { tabId: tab.id }, 180 | files: ['popup.js'] 181 | }) 182 | ); 183 | 184 | // Toolbar button 185 | chrome.action.onClicked.addListener((tab) => { 186 | chrome.scripting.executeScript({ 187 | target: { tabId: tab.id }, 188 | files: ['popup.js'] 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /bootstrap.bundle.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v5.3.0 (https://getbootstrap.com/) 3 | * Copyright 2011-2023 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 5 | */ 6 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=N(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return M(s,{delegateTarget:r}),n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return M(n,{delegateTarget:t}),i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function I(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function N(t){return t=t.replace(y,""),T[t]||t}const P={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))I(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==N(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=M(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function M(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function j(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function F(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const H={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${F(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${F(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=j(t.dataset[n])}return e},getDataAttribute:(t,e)=>j(t.getAttribute(`data-bs-${F(e)}`))};class ${static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?H.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?H.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends ${constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),P.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.0"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return n(e)},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;P.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))};class q extends W{static get NAME(){return"alert"}close(){if(P.trigger(this._element,"close.bs.alert").defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),P.trigger(this._element,"closed.bs.alert"),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(q,"close"),m(q);const V='[data-bs-toggle="button"]';class K extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=K.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}P.on(document,"click.bs.button.data-api",V,(t=>{t.preventDefault();const e=t.target.closest(V);K.getOrCreateInstance(e).toggle()})),m(K);const Q={endCallback:null,leftCallback:null,rightCallback:null},X={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class Y extends ${constructor(t,e){super(),this._element=t,t&&Y.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return Q}static get DefaultType(){return X}static get NAME(){return"swipe"}dispose(){P.off(this._element,".bs.swipe")}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(P.on(this._element,"pointerdown.bs.swipe",(t=>this._start(t))),P.on(this._element,"pointerup.bs.swipe",(t=>this._end(t))),this._element.classList.add("pointer-event")):(P.on(this._element,"touchstart.bs.swipe",(t=>this._start(t))),P.on(this._element,"touchmove.bs.swipe",(t=>this._move(t))),P.on(this._element,"touchend.bs.swipe",(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const U="next",G="prev",J="left",Z="right",tt="slid.bs.carousel",et="carousel",it="active",nt={ArrowLeft:Z,ArrowRight:J},st={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class rt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===et&&this.cycle()}static get Default(){return st}static get DefaultType(){return ot}static get NAME(){return"carousel"}next(){this._slide(U)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(G)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?P.one(this._element,tt,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void P.one(this._element,tt,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?U:G;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&P.on(this._element,"keydown.bs.carousel",(t=>this._keydown(t))),"hover"===this._config.pause&&(P.on(this._element,"mouseenter.bs.carousel",(()=>this.pause())),P.on(this._element,"mouseleave.bs.carousel",(()=>this._maybeEnableCycle()))),this._config.touch&&Y.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))P.on(t,"dragstart.bs.carousel",(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(J)),rightCallback:()=>this._slide(this._directionToOrder(Z)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new Y(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=nt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(".active",this._indicatorsElement);e.classList.remove(it),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(it),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===U,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>P.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r("slide.bs.carousel").defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(it),i.classList.remove(it,c,l),this._isSliding=!1,r(tt)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(".active.carousel-item",this._element)}_getItems(){return z.find(".carousel-item",this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===J?G:U:t===J?U:G}_orderToDirection(t){return p()?t===G?J:Z:t===G?Z:J}static jQueryInterface(t){return this.each((function(){const e=rt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}P.on(document,"click.bs.carousel.data-api","[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(et))return;t.preventDefault();const i=rt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===H.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),P.on(window,"load.bs.carousel.data-api",(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)rt.getOrCreateInstance(e)})),m(rt);const at="show",lt="collapse",ct="collapsing",ht='[data-bs-toggle="collapse"]',dt={parent:null,toggle:!0},ut={parent:"(null|element)",toggle:"boolean"};class ft extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(ht);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return dt}static get DefaultType(){return ut}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>ft.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(P.trigger(this._element,"show.bs.collapse").defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(lt),this._element.classList.add(ct),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ct),this._element.classList.add(lt,at),this._element.style[e]="",P.trigger(this._element,"shown.bs.collapse")}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(P.trigger(this._element,"hide.bs.collapse").defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(ct),this._element.classList.remove(lt,at);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(ct),this._element.classList.add(lt),P.trigger(this._element,"hidden.bs.collapse")}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(at)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(ht);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(":scope .collapse .collapse",this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=ft.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}P.on(document,"click.bs.collapse.data-api",ht,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))ft.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(ft);var pt="top",mt="bottom",gt="right",_t="left",bt="auto",vt=[pt,mt,gt,_t],yt="start",wt="end",At="clippingParents",Et="viewport",Tt="popper",Ct="reference",Ot=vt.reduce((function(t,e){return t.concat([e+"-"+yt,e+"-"+wt])}),[]),xt=[].concat(vt,[bt]).reduce((function(t,e){return t.concat([e,e+"-"+yt,e+"-"+wt])}),[]),kt="beforeRead",Lt="read",St="afterRead",Dt="beforeMain",It="main",Nt="afterMain",Pt="beforeWrite",Mt="write",jt="afterWrite",Ft=[kt,Lt,St,Dt,It,Nt,Pt,Mt,jt];function Ht(t){return t?(t.nodeName||"").toLowerCase():null}function $t(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function Wt(t){return t instanceof $t(t).Element||t instanceof Element}function Bt(t){return t instanceof $t(t).HTMLElement||t instanceof HTMLElement}function zt(t){return"undefined"!=typeof ShadowRoot&&(t instanceof $t(t).ShadowRoot||t instanceof ShadowRoot)}const Rt={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];Bt(s)&&Ht(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});Bt(n)&&Ht(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function qt(t){return t.split("-")[0]}var Vt=Math.max,Kt=Math.min,Qt=Math.round;function Xt(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Yt(){return!/^((?!chrome|android).)*safari/i.test(Xt())}function Ut(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&Bt(t)&&(s=t.offsetWidth>0&&Qt(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&Qt(n.height)/t.offsetHeight||1);var r=(Wt(t)?$t(t):window).visualViewport,a=!Yt()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Gt(t){var e=Ut(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Jt(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&zt(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function Zt(t){return $t(t).getComputedStyle(t)}function te(t){return["table","td","th"].indexOf(Ht(t))>=0}function ee(t){return((Wt(t)?t.ownerDocument:t.document)||window.document).documentElement}function ie(t){return"html"===Ht(t)?t:t.assignedSlot||t.parentNode||(zt(t)?t.host:null)||ee(t)}function ne(t){return Bt(t)&&"fixed"!==Zt(t).position?t.offsetParent:null}function se(t){for(var e=$t(t),i=ne(t);i&&te(i)&&"static"===Zt(i).position;)i=ne(i);return i&&("html"===Ht(i)||"body"===Ht(i)&&"static"===Zt(i).position)?e:i||function(t){var e=/firefox/i.test(Xt());if(/Trident/i.test(Xt())&&Bt(t)&&"fixed"===Zt(t).position)return null;var i=ie(t);for(zt(i)&&(i=i.host);Bt(i)&&["html","body"].indexOf(Ht(i))<0;){var n=Zt(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function oe(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function re(t,e,i){return Vt(t,Kt(e,i))}function ae(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function le(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const ce={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=qt(i.placement),l=oe(a),c=[_t,gt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return ae("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:le(t,vt))}(s.padding,i),d=Gt(o),u="y"===l?pt:_t,f="y"===l?mt:gt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=se(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=re(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Jt(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function he(t){return t.split("-")[1]}var de={top:"auto",right:"auto",bottom:"auto",left:"auto"};function ue(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=_t,y=pt,w=window;if(c){var A=se(i),E="clientHeight",T="clientWidth";A===$t(i)&&"static"!==Zt(A=ee(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===pt||(s===_t||s===gt)&&o===wt)&&(y=mt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==_t&&(s!==pt&&s!==mt||o!==wt)||(v=gt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&de),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:Qt(i*s)/s||0,y:Qt(n*s)/s||0}}({x:f,y:m},$t(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const fe={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:qt(e.placement),variation:he(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,ue(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,ue(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var pe={passive:!0};const me={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=$t(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,pe)})),a&&l.addEventListener("resize",i.update,pe),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,pe)})),a&&l.removeEventListener("resize",i.update,pe)}},data:{}};var ge={left:"right",right:"left",bottom:"top",top:"bottom"};function _e(t){return t.replace(/left|right|bottom|top/g,(function(t){return ge[t]}))}var be={start:"end",end:"start"};function ve(t){return t.replace(/start|end/g,(function(t){return be[t]}))}function ye(t){var e=$t(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function we(t){return Ut(ee(t)).left+ye(t).scrollLeft}function Ae(t){var e=Zt(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ee(t){return["html","body","#document"].indexOf(Ht(t))>=0?t.ownerDocument.body:Bt(t)&&Ae(t)?t:Ee(ie(t))}function Te(t,e){var i;void 0===e&&(e=[]);var n=Ee(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=$t(n),r=s?[o].concat(o.visualViewport||[],Ae(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Te(ie(r)))}function Ce(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function Oe(t,e,i){return e===Et?Ce(function(t,e){var i=$t(t),n=ee(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Yt();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+we(t),y:l}}(t,i)):Wt(e)?function(t,e){var i=Ut(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ce(function(t){var e,i=ee(t),n=ye(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=Vt(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=Vt(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+we(t),l=-n.scrollTop;return"rtl"===Zt(s||i).direction&&(a+=Vt(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(ee(t)))}function xe(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?qt(s):null,r=s?he(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case pt:e={x:a,y:i.y-n.height};break;case mt:e={x:a,y:i.y+i.height};break;case gt:e={x:i.x+i.width,y:l};break;case _t:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?oe(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case yt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case wt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ke(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?At:a,c=i.rootBoundary,h=void 0===c?Et:c,d=i.elementContext,u=void 0===d?Tt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=ae("number"!=typeof g?g:le(g,vt)),b=u===Tt?Ct:Tt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Te(ie(t)),i=["absolute","fixed"].indexOf(Zt(t).position)>=0&&Bt(t)?se(t):t;return Wt(i)?e.filter((function(t){return Wt(t)&&Jt(t,i)&&"body"!==Ht(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=Oe(t,i,n);return e.top=Vt(s.top,e.top),e.right=Kt(s.right,e.right),e.bottom=Kt(s.bottom,e.bottom),e.left=Vt(s.left,e.left),e}),Oe(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(Wt(y)?y:y.contextElement||ee(t.elements.popper),l,h,r),A=Ut(t.elements.reference),E=xe({reference:A,element:v,strategy:"absolute",placement:s}),T=Ce(Object.assign({},v,E)),C=u===Tt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Tt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[gt,mt].indexOf(t)>=0?1:-1,i=[pt,mt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function Le(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?xt:l,h=he(n),d=h?a?Ot:Ot.filter((function(t){return he(t)===h})):vt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ke(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[qt(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const Se={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=qt(g),b=l||(_!==g&&p?function(t){if(qt(t)===bt)return[];var e=_e(t);return[ve(t),e,ve(e)]}(g):[_e(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(qt(i)===bt?Le(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=ke(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),I=L?k?gt:_t:k?mt:pt;y[S]>w[S]&&(I=_e(I));var N=_e(I),P=[];if(o&&P.push(D[x]<=0),a&&P.push(D[I]<=0,D[N]<=0),P.every((function(t){return t}))){T=O,E=!1;break}A.set(O,P)}if(E)for(var M=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},j=p?3:1;j>0&&"break"!==M(j);j--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function De(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function Ie(t){return[pt,gt,mt,_t].some((function(e){return t[e]>=0}))}const Ne={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ke(e,{elementContext:"reference"}),a=ke(e,{altBoundary:!0}),l=De(r,n),c=De(a,s,o),h=Ie(l),d=Ie(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},Pe={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=xt.reduce((function(t,i){return t[i]=function(t,e,i){var n=qt(t),s=[_t,pt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[_t,gt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},Me={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=xe({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},je={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ke(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=qt(e.placement),b=he(e.placement),v=!b,y=oe(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?pt:_t,D="y"===y?mt:gt,I="y"===y?"height":"width",N=A[y],P=N+g[S],M=N-g[D],j=f?-T[I]/2:0,F=b===yt?E[I]:T[I],H=b===yt?-T[I]:-E[I],$=e.elements.arrow,W=f&&$?Gt($):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=re(0,E[I],W[I]),V=v?E[I]/2-j-q-z-O.mainAxis:F-q-z-O.mainAxis,K=v?-E[I]/2+j+q+R+O.mainAxis:H+q+R+O.mainAxis,Q=e.elements.arrow&&se(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=N+K-Y,G=re(f?Kt(P,N+V-Y-X):P,N,f?Vt(M,U):M);A[y]=G,k[y]=G-N}if(a){var J,Z="x"===y?pt:_t,tt="x"===y?mt:gt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[pt,_t].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=re(t,e,i);return n>i?i:n}(at,et,lt):re(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function Fe(t,e,i){void 0===i&&(i=!1);var n,s,o=Bt(e),r=Bt(e)&&function(t){var e=t.getBoundingClientRect(),i=Qt(e.width)/t.offsetWidth||1,n=Qt(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=ee(e),l=Ut(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==Ht(e)||Ae(a))&&(c=(n=e)!==$t(n)&&Bt(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:ye(n)),Bt(e)?((h=Ut(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=we(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function He(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var $e={placement:"bottom",modifiers:[],strategy:"absolute"};function We(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(H.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Xe,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=ci.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ze);for(const i of e){const e=ci.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Qe,Xe].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Je)?this:z.prev(this,Je)[0]||z.next(this,Je)[0]||z.findOne(Je,t.delegateTarget.parentNode),o=ci.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}P.on(document,Ue,Je,ci.dataApiKeydownHandler),P.on(document,Ue,ti,ci.dataApiKeydownHandler),P.on(document,Ye,ci.clearMenus),P.on(document,"keyup.bs.dropdown.data-api",ci.clearMenus),P.on(document,Ye,Je,(function(t){t.preventDefault(),ci.getOrCreateInstance(this).toggle()})),m(ci);const hi="show",di="mousedown.bs.backdrop",ui={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},fi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class pi extends ${constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return ui}static get DefaultType(){return fi}static get NAME(){return"backdrop"}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(hi),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(hi),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(P.off(this._element,di),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),P.on(t,di,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const mi=".bs.focustrap",gi="backward",_i={autofocus:!0,trapElement:null},bi={autofocus:"boolean",trapElement:"element"};class vi extends ${constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return _i}static get DefaultType(){return bi}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),P.off(document,mi),P.on(document,"focusin.bs.focustrap",(t=>this._handleFocusin(t))),P.on(document,"keydown.tab.bs.focustrap",(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,P.off(document,mi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===gi?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?gi:"forward")}}const yi=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",wi=".sticky-top",Ai="padding-right",Ei="margin-right";class Ti{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,Ai,(e=>e+t)),this._setElementAttributes(yi,Ai,(e=>e+t)),this._setElementAttributes(wi,Ei,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,Ai),this._resetElementAttributes(yi,Ai),this._resetElementAttributes(wi,Ei)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&H.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=H.getDataAttribute(t,e);null!==i?(H.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const Ci=".bs.modal",Oi="hidden.bs.modal",xi="show.bs.modal",ki="modal-open",Li="show",Si="modal-static",Di={backdrop:!0,focus:!0,keyboard:!0},Ii={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class Ni extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new Ti,this._addEventListeners()}static get Default(){return Di}static get DefaultType(){return Ii}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||P.trigger(this._element,xi,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(ki),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(P.trigger(this._element,"hide.bs.modal").defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(Li),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){P.off(window,Ci),P.off(this._dialog,Ci),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new pi({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new vi({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(Li),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,P.trigger(this._element,"shown.bs.modal",{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){P.on(this._element,"keydown.dismiss.bs.modal",(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),P.on(window,"resize.bs.modal",(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),P.on(this._element,"mousedown.dismiss.bs.modal",(t=>{P.one(this._element,"click.dismiss.bs.modal",(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(ki),this._resetAdjustments(),this._scrollBar.reset(),P.trigger(this._element,Oi)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(P.trigger(this._element,"hidePrevented.bs.modal").defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(Si)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(Si),this._queueCallback((()=>{this._element.classList.remove(Si),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=Ni.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}P.on(document,"click.bs.modal.data-api",'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),P.one(e,xi,(t=>{t.defaultPrevented||P.one(e,Oi,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&Ni.getInstance(i).hide(),Ni.getOrCreateInstance(e).toggle(this)})),R(Ni),m(Ni);const Pi="show",Mi="showing",ji="hiding",Fi=".offcanvas.show",Hi="hidePrevented.bs.offcanvas",$i="hidden.bs.offcanvas",Wi={backdrop:!0,keyboard:!0,scroll:!1},Bi={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class zi extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return Wi}static get DefaultType(){return Bi}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||P.trigger(this._element,"show.bs.offcanvas",{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new Ti).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Mi),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Pi),this._element.classList.remove(Mi),P.trigger(this._element,"shown.bs.offcanvas",{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(P.trigger(this._element,"hide.bs.offcanvas").defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add(ji),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Pi,ji),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new Ti).reset(),P.trigger(this._element,$i)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new pi({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():P.trigger(this._element,Hi)}:null})}_initializeFocusTrap(){return new vi({trapElement:this._element})}_addEventListeners(){P.on(this._element,"keydown.dismiss.bs.offcanvas",(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():P.trigger(this._element,Hi))}))}static jQueryInterface(t){return this.each((function(){const e=zi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}P.on(document,"click.bs.offcanvas.data-api",'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;P.one(e,$i,(()=>{a(this)&&this.focus()}));const i=z.findOne(Fi);i&&i!==e&&zi.getInstance(i).hide(),zi.getOrCreateInstance(e).toggle(this)})),P.on(window,"load.bs.offcanvas.data-api",(()=>{for(const t of z.find(Fi))zi.getOrCreateInstance(t).show()})),P.on(window,"resize.bs.offcanvas",(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&zi.getOrCreateInstance(t).hide()})),R(zi),m(zi);const Ri={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],div:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},qi=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Vi=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Ki=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!qi.has(i)||Boolean(Vi.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Qi={allowList:Ri,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Xi={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Yi={entry:"(string|element|function|null)",selector:"(string|element)"};class Ui extends ${constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Qi}static get DefaultType(){return Xi}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Yi)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Ki(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Gi=new Set(["sanitize","allowList","sanitizeFn"]),Ji="fade",Zi="show",tn=".modal",en="hide.bs.modal",nn="hover",sn="focus",on={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},rn={allowList:Ri,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},an={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class ln extends W{constructor(t,e){if(void 0===Ve)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return rn}static get DefaultType(){return an}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),P.off(this._element.closest(tn),en,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=P.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),P.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(Zi),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))P.on(t,"mouseover",h);this._queueCallback((()=>{P.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!P.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(Zi),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))P.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger.focus=!1,this._activeTrigger.hover=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),P.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(Ji,Zi),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(Ji),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Ui({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(Ji)}_isShown(){return this.tip&&this.tip.classList.contains(Zi)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=on[e.toUpperCase()];return qe(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)P.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===nn?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===nn?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");P.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?sn:nn]=!0,e._enter()})),P.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?sn:nn]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},P.on(this._element.closest(tn),en,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=H.getDataAttributes(this._element);for(const t of Object.keys(e))Gi.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof 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()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=ln.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(ln);const cn={...ln.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},hn={...ln.DefaultType,content:"(null|string|element|function)"};class dn extends ln{static get Default(){return cn}static get DefaultType(){return hn}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=dn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(dn);const un="click.bs.scrollspy",fn="active",pn="[href]",mn={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},gn={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class _n extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return mn}static get DefaultType(){return gn}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(P.off(this._config.target,un),P.on(this._config.target,un,pn,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(pn,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(fn),this._activateParents(t),P.trigger(this._element,"activate.bs.scrollspy",{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(fn);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,".nav-link, .nav-item > .nav-link, .list-group-item"))t.classList.add(fn)}_clearActiveClass(t){t.classList.remove(fn);const e=z.find("[href].active",t);for(const t of e)t.classList.remove(fn)}static jQueryInterface(t){return this.each((function(){const e=_n.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}P.on(window,"load.bs.scrollspy.data-api",(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))_n.getOrCreateInstance(t)})),m(_n);const bn="ArrowLeft",vn="ArrowRight",yn="ArrowUp",wn="ArrowDown",An="active",En="fade",Tn="show",Cn='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',On=`.nav-link:not(.dropdown-toggle), .list-group-item:not(.dropdown-toggle), [role="tab"]:not(.dropdown-toggle), ${Cn}`;class xn extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),P.on(this._element,"keydown.bs.tab",(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?P.trigger(e,"hide.bs.tab",{relatedTarget:t}):null;P.trigger(t,"show.bs.tab",{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(An),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),P.trigger(t,"shown.bs.tab",{relatedTarget:e})):t.classList.add(Tn)}),t,t.classList.contains(En)))}_deactivate(t,e){t&&(t.classList.remove(An),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),P.trigger(t,"hidden.bs.tab",{relatedTarget:e})):t.classList.remove(Tn)}),t,t.classList.contains(En)))}_keydown(t){if(![bn,vn,yn,wn].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=[vn,wn].includes(t.key),i=b(this._getChildren().filter((t=>!l(t))),t.target,e,!0);i&&(i.focus({preventScroll:!0}),xn.getOrCreateInstance(i).show())}_getChildren(){return z.find(On,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(".dropdown-toggle",An),n(".dropdown-menu",Tn),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(An)}_getInnerElement(t){return t.matches(On)?t:z.findOne(On,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=xn.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}P.on(document,"click.bs.tab",Cn,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||xn.getOrCreateInstance(this).show()})),P.on(window,"load.bs.tab",(()=>{for(const t of z.find('.active[data-bs-toggle="tab"], .active[data-bs-toggle="pill"], .active[data-bs-toggle="list"]'))xn.getOrCreateInstance(t)})),m(xn);const kn="hide",Ln="show",Sn="showing",Dn={animation:"boolean",autohide:"boolean",delay:"number"},In={animation:!0,autohide:!0,delay:5e3};class Nn extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return In}static get DefaultType(){return Dn}static get NAME(){return"toast"}show(){P.trigger(this._element,"show.bs.toast").defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(kn),d(this._element),this._element.classList.add(Ln,Sn),this._queueCallback((()=>{this._element.classList.remove(Sn),P.trigger(this._element,"shown.bs.toast"),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(P.trigger(this._element,"hide.bs.toast").defaultPrevented||(this._element.classList.add(Sn),this._queueCallback((()=>{this._element.classList.add(kn),this._element.classList.remove(Sn,Ln),P.trigger(this._element,"hidden.bs.toast")}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(Ln),super.dispose()}isShown(){return this._element.classList.contains(Ln)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){P.on(this._element,"mouseover.bs.toast",(t=>this._onInteraction(t,!0))),P.on(this._element,"mouseout.bs.toast",(t=>this._onInteraction(t,!1))),P.on(this._element,"focusin.bs.toast",(t=>this._onInteraction(t,!0))),P.on(this._element,"focusout.bs.toast",(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=Nn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(Nn),m(Nn),{Alert:q,Button:K,Carousel:rt,Collapse:ft,Dropdown:ci,Modal:Ni,Offcanvas:zi,Popover:dn,ScrollSpy:_n,Tab:xn,Toast:Nn,Tooltip:ln}})); 7 | //# sourceMappingURL=bootstrap.bundle.min.js.map -------------------------------------------------------------------------------- /config-tab.js: -------------------------------------------------------------------------------- 1 | // Config tab initialization and handlers 2 | 3 | import { Snapshot, updateStatusIndicator, getArchiveBoxServerUrl, addToArchiveBox } from './utils.js'; 4 | 5 | export async function initializeConfigTab() { 6 | const configForm = document.getElementById('configForm'); 7 | const serverUrl = document.getElementById('archivebox_server_url'); 8 | const apiKey = document.getElementById('archivebox_api_key'); 9 | const matchUrls = document.getElementById('match_urls'); 10 | const excludeUrls = document.getElementById('exclude_urls'); 11 | 12 | // Load saved values 13 | const archivebox_server_url = await getArchiveBoxServerUrl(); 14 | const { archivebox_api_key='', match_urls='', exclude_urls='', enable_auto_archive=false } = await chrome.storage.local.get([ 15 | 'archivebox_api_key', 16 | 'match_urls', 17 | 'exclude_urls', 18 | 'enable_auto_archive', 19 | ]); 20 | console.log('Got config values from storage:', archivebox_server_url, archivebox_api_key, match_urls, exclude_urls, enable_auto_archive); 21 | 22 | // Migrate old config_archiveboxBaseUrl to archivebox_server_url 23 | const {config_archiveBoxBaseUrl} = await chrome.storage.sync.get('config_archiveboxBaseUrl'); 24 | if (config_archiveBoxBaseUrl) { 25 | await chrome.storage.local.set({ archivebox_server_url: config_archiveBoxBaseUrl }); 26 | } 27 | 28 | serverUrl.value = archivebox_server_url || ''; 29 | apiKey.value = archivebox_api_key || ''; 30 | matchUrls.value = typeof match_urls === 'string' ? match_urls : ''; 31 | excludeUrls.value = typeof exclude_urls === 'string' ? exclude_urls : ''; 32 | 33 | // Set the auto-archive toggle state 34 | const autoArchiveCheckbox = document.getElementById('enable_auto_archive'); 35 | autoArchiveCheckbox.checked = !!enable_auto_archive; 36 | 37 | // Server test button handler 38 | document.getElementById('testServer').addEventListener('click', async () => { 39 | const statusIndicator = document.getElementById('serverStatus'); 40 | const statusText = document.getElementById('serverStatusText'); 41 | 42 | // Check if we have permission to access the server 43 | const permission = await chrome.permissions.request({permissions: ['cookies'], origins: [`${serverUrl.value}/*`]}); 44 | if (!permission) { 45 | alert('Permission denied.'); 46 | return; 47 | } 48 | 49 | // Test request to server. 50 | try { 51 | let response = await fetch(`${serverUrl.value}/api/`, { 52 | method: 'GET', 53 | mode: 'cors', 54 | credentials: 'omit' 55 | }); 56 | 57 | // fall back to pre-v0.8.0 endpoint for backwards compatibility 58 | if (response.status === 404) { 59 | response = await fetch(`${serverUrl.value}`, { 60 | method: 'GET', 61 | mode: 'cors', 62 | credentials: 'omit' 63 | }); 64 | } 65 | 66 | if (response.ok) { 67 | updateStatusIndicator(statusIndicator, statusText, true, '✓ Server is reachable'); 68 | } else { 69 | updateStatusIndicator(statusIndicator, statusText, false, `✗ Server error: ${response.status} ${response.statusText}`); 70 | } 71 | } catch (err) { 72 | updateStatusIndicator(statusIndicator, statusText, false, `✗ Connection failed: ${err.message}`); 73 | } 74 | }); 75 | 76 | // API key test button handler 77 | document.getElementById('testApiKey').addEventListener('click', async () => { 78 | const statusIndicator = document.getElementById('apiKeyStatus'); 79 | const statusText = document.getElementById('apiKeyStatusText'); 80 | 81 | try { 82 | const response = await fetch(`${serverUrl.value}/api/v1/auth/check_api_token`, { 83 | method: 'POST', 84 | mode: 'cors', 85 | credentials: 'omit', 86 | body: JSON.stringify({ 87 | token: apiKey.value, 88 | }) 89 | }); 90 | const data = await response.json(); 91 | 92 | if (data.user_id) { 93 | updateStatusIndicator(statusIndicator, statusText, true, `✓ API key is valid: user_id = ${data.user_id}`); 94 | } else { 95 | updateStatusIndicator(statusIndicator, statusText, false, `✗ API key error: ${response.status} ${response.statusText} ${JSON.stringify(data)}`); 96 | } 97 | } catch (err) { 98 | updateStatusIndicator(statusIndicator, statusText, false, `✗ API test failed: ${err.message}`); 99 | } 100 | }); 101 | 102 | // Generate API key button handler 103 | document.getElementById('generateApiKey').addEventListener('click', () => { 104 | if (serverUrl.value) { 105 | window.open(`${serverUrl.value}/admin/api/apitoken/add/`, '_blank'); 106 | } else { 107 | alert('Please enter a server URL first'); 108 | } 109 | }); 110 | 111 | // Login server button handler 112 | document.getElementById('loginServer').addEventListener('click', () => { 113 | if (serverUrl.value) { 114 | window.open(`${serverUrl.value}/admin`, '_blank'); 115 | } 116 | }); 117 | document.getElementById('loginAdminUILink').addEventListener('click', () => { 118 | if (serverUrl.value) { 119 | window.open(`${serverUrl.value}/admin/login/`, '_blank'); 120 | } 121 | }); 122 | document.getElementById('generateApiKeyLink').addEventListener('click', () => { 123 | if (serverUrl.value) { 124 | window.open(`${serverUrl.value}/admin/api/apitoken/add/`, '_blank'); 125 | } 126 | }); 127 | 128 | // Special handler for the auto-archive toggle 129 | autoArchiveCheckbox.addEventListener('change', async () => { 130 | if (autoArchiveCheckbox.checked) { 131 | const granted = await chrome.permissions.request({ permissions: ['tabs'] }); 132 | if (!granted) { 133 | autoArchiveCheckbox.checked = false; 134 | alert('The "tabs" permission is required for auto-archiving. Auto-archiving has been disabled.'); 135 | } 136 | } 137 | 138 | await chrome.storage.local.set({ 139 | enable_auto_archive: autoArchiveCheckbox.checked 140 | }); 141 | }); 142 | 143 | // Other inputs 144 | [serverUrl, apiKey, matchUrls, excludeUrls].forEach(input => { 145 | input.addEventListener('change', async () => { 146 | await chrome.storage.local.set({ 147 | archivebox_server_url: serverUrl.value.replace(/\/$/, ''), 148 | archivebox_api_key: apiKey.value.trim(), 149 | match_urls: matchUrls.value, 150 | exclude_urls: excludeUrls.value, 151 | }); 152 | }); 153 | }); 154 | 155 | // Test URL functionality 156 | const testUrlInput = document.getElementById('testUrl'); 157 | const testButton = document.getElementById('testAdding'); 158 | const testStatus = document.getElementById('urlStatusText'); 159 | 160 | testButton.addEventListener('click', async () => { 161 | const url = testUrlInput.value.trim(); 162 | 163 | if (!url) { 164 | testStatus.innerHTML = ` 165 | 166 | ⌨️ Please enter a URL to test 167 | `; 168 | return; 169 | } 170 | 171 | // test if the URL matches the regex match patterns 172 | let shouldArchive = false; 173 | let matchPattern; 174 | try { 175 | matchPattern = new RegExp(matchUrls.value || /^$/); 176 | } catch (error) { 177 | testStatus.innerHTML = ` 178 | 179 | Error with match pattern: ${error.message}
180 | `; 181 | return; 182 | } 183 | 184 | if (matchPattern.test(url)) { 185 | testStatus.innerHTML = ` 186 | 187 | ➕ URL would be auto-archived when visited
188 | `; 189 | shouldArchive = true; 190 | } else { 191 | testStatus.innerHTML = ` 192 | 193 | ☝ URL does not match the auto-archive pattern (but it can still be saved manually)
194 | `; 195 | } 196 | 197 | // test if the URL matches the regex exclude patterns 198 | let excludePattern; 199 | try { 200 | excludePattern = new RegExp(excludeUrls.value || /^$/); 201 | if (excludePattern.test(url)) { 202 | testStatus.innerHTML = ` 203 | 204 | 🚫 URL is excluded from auto-archiving (but it can still be saved manually)
205 | `; 206 | shouldArchive = false; 207 | } 208 | } catch (error) { 209 | testStatus.innerHTML = ` 210 | 211 | Error with exclude pattern: ${error.message}
212 | `; 213 | } 214 | 215 | if (shouldArchive) { 216 | // Show loading state 217 | testButton.disabled = true; 218 | testStatus.innerHTML += ` 219 | 220 |     221 | Submitting... 222 | 223 | `; 224 | 225 | try { 226 | const testSnapshot = new Snapshot(url, ['test'], 'Test Snapshot'); 227 | 228 | document.getElementById('inprogress-test').remove(); 229 | 230 | await addToArchiveBox([testSnapshot.url], testSnapshot.tags); 231 | 232 | testStatus.innerHTML += ` 233 |   234 | 🚀 URL was submitted and ✓ queued for archiving on the ArchiveBox server: 📦 ${serverUrl.value}/archive/${testSnapshot.url}. 235 | `; 236 | // Clear the input on success 237 | testUrlInput.value = ''; 238 | } catch (error) { 239 | testStatus.innerHTML += ` 240 | 241 | Error: ${error.message} 242 | `; 243 | } finally { 244 | testButton.disabled = false; 245 | } 246 | } 247 | }); 248 | 249 | // Add Enter key support for test URL input 250 | testUrlInput.addEventListener('keypress', (e) => { 251 | if (e.key === 'Enter') { 252 | e.preventDefault(); 253 | testButton.click(); 254 | } 255 | }); 256 | } 257 | -------------------------------------------------------------------------------- /cookies-tab.js: -------------------------------------------------------------------------------- 1 | let availableCookies = []; 2 | let selectedCookieDomains = new Set(); 3 | 4 | import { formatCookiesForExport } from './utils.js'; 5 | 6 | export async function loadAvailableCookies() { 7 | 8 | 9 | const allCookies = await chrome.cookies.getAll({}); 10 | 11 | // Group cookies by domain 12 | const cookiesByDomain = {}; 13 | for (const cookie of allCookies) { 14 | const domain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain; 15 | cookiesByDomain[domain] = cookiesByDomain[domain] || []; 16 | cookiesByDomain[domain].push({ 17 | name: cookie.name, 18 | value: cookie.value, 19 | domain: cookie.domain, 20 | path: cookie.path, 21 | secure: cookie.secure, 22 | httpOnly: cookie.httpOnly, 23 | sameSite: cookie.sameSite, 24 | expirationDate: cookie.expirationDate 25 | }); 26 | } 27 | 28 | availableCookies = Object.entries(cookiesByDomain).map(([domain, cookies]) => ({ 29 | domain, 30 | cookies, 31 | selected: selectedCookieDomains.has(domain) 32 | })); 33 | 34 | renderCookieTable(); 35 | } 36 | 37 | function renderCookieTable(filterText = '') { 38 | const tbody = document.getElementById('cookieTable').querySelector('tbody'); 39 | const filteredCookies = availableCookies.filter(item => 40 | item.domain.toLowerCase().includes(filterText.toLowerCase()) 41 | ); 42 | 43 | tbody.innerHTML = filteredCookies.map(item => ` 44 | 45 | 46 | 48 | 49 | ${item.domain} 50 | ${item.cookies.length} 51 | 52 | 56 | 57 | 58 | `).join(''); 59 | 60 | updateSelectedCount(); 61 | } 62 | 63 | function updateSelectedCount() { 64 | const count = selectedCookieDomains.size; 65 | document.getElementById('selectedCookieCount').textContent = count; 66 | document.getElementById('importCookies').disabled = count === 0 || !document.getElementById('activePersona').value; 67 | } 68 | 69 | function toggleAllCookieSelection(selected) { 70 | const filterText = document.getElementById('cookieFilter').value.toLowerCase(); 71 | 72 | availableCookies.forEach(item => { 73 | // Only toggle selection if the item matches the current filter 74 | if (item.domain.toLowerCase().includes(filterText)) { 75 | if (selected) { 76 | selectedCookieDomains.add(item.domain); 77 | item.selected = true; 78 | } else { 79 | selectedCookieDomains.delete(item.domain); 80 | item.selected = false; 81 | } 82 | } 83 | }); 84 | 85 | renderCookieTable(filterText); 86 | } 87 | 88 | async function previewCookies(domain) { 89 | const cookies = availableCookies.find(item => item.domain === domain)?.cookies || []; 90 | const text = formatCookiesForExport({ [domain]: cookies }); 91 | await navigator.clipboard.writeText(text); 92 | alert(`${cookies.length} cookies copied to clipboard for "${domain}"! Save them into cookies.txt on your ArchiveBox server and run: archivebox config --set COOKIES_FILE=/path/to/cookies.txt`); 93 | } 94 | 95 | // Using formatCookiesForExport from utils.js 96 | 97 | async function importSelectedCookies() { 98 | const { activePersona } = await chrome.storage.local.get('activePersona'); 99 | if (!activePersona) { 100 | alert('Please select an active persona first'); 101 | return; 102 | } 103 | 104 | const { personas = [] } = await chrome.storage.local.get('personas'); 105 | const persona = personas.find(p => p.id === activePersona); 106 | if (!persona) { 107 | alert('Selected persona not found'); 108 | return; 109 | } 110 | 111 | // Initialize cookies object if it doesn't exist 112 | persona.cookies = persona.cookies || {}; 113 | 114 | // Import selected cookies 115 | let importCount = 0; 116 | for (const item of availableCookies) { 117 | if (selectedCookieDomains.has(item.domain)) { 118 | persona.cookies[item.domain] = item.cookies; 119 | importCount++; 120 | } 121 | } 122 | 123 | persona.lastUsed = new Date().toISOString(); 124 | 125 | // Save updated personas 126 | await chrome.storage.local.set({ personas }); 127 | 128 | // Clear selection 129 | selectedCookieDomains.clear(); 130 | availableCookies.forEach(item => item.selected = false); 131 | 132 | // Refresh UI 133 | renderCookieTable(document.getElementById('cookieFilter').value); 134 | 135 | alert(`Successfully imported ${importCount} domain cookies into the "${persona.name}" persona`); 136 | await window.loadPersonas(); 137 | } 138 | 139 | export function initializeCookiesTab() { 140 | // Cookie management 141 | document.getElementById('cookieFilter').addEventListener('input', e => 142 | renderCookieTable(e.target.value) 143 | ); 144 | 145 | ['selectAllCookies', 'selectAllCookiesBottom'].forEach(id => 146 | document.getElementById(id).addEventListener('click', () => toggleAllCookieSelection(true)) 147 | ); 148 | 149 | ['deselectAllCookies', 'deselectAllCookiesBottom'].forEach(id => 150 | document.getElementById(id).addEventListener('click', () => toggleAllCookieSelection(false)) 151 | ); 152 | 153 | document.getElementById('cookieTable').addEventListener('click', async e => { 154 | const checkbox = e.target.closest('.cookie-select'); 155 | if (checkbox) { 156 | const domain = checkbox.dataset.domain; 157 | const item = availableCookies.find(i => i.domain === domain); 158 | if (item) { 159 | item.selected = checkbox.checked; 160 | if (checkbox.checked) { 161 | selectedCookieDomains.add(domain); 162 | } else { 163 | selectedCookieDomains.delete(domain); 164 | } 165 | updateSelectedCount(); 166 | } 167 | return; 168 | } 169 | 170 | const previewButton = e.target.closest('.preview-cookies'); 171 | if (previewButton) { 172 | await previewCookies(previewButton.dataset.domain); 173 | } 174 | }); 175 | 176 | document.getElementById('importCookies').addEventListener('click', importSelectedCookies); 177 | 178 | } 179 | 180 | document.getElementById('requestCookiesPermission').addEventListener('click', async () => { 181 | // request permission to access cookies 182 | const permission = await chrome.permissions.request({permissions: ['cookies'], origins: ['*://*\/*']}); 183 | if (!permission) { 184 | alert('Permission denied.'); 185 | return; 186 | } else { 187 | loadAvailableCookies(); 188 | renderCookieTable(document.getElementById('cookieFilter').value); 189 | } 190 | }); 191 | -------------------------------------------------------------------------------- /fonts/bootstrap-icons.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArchiveBox/archivebox-browser-extension/4154bd105cc09f5fa54e2ba783e10d7065fd1436/fonts/bootstrap-icons.woff -------------------------------------------------------------------------------- /fonts/bootstrap-icons.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArchiveBox/archivebox-browser-extension/4154bd105cc09f5fa54e2ba783e10d7065fd1436/fonts/bootstrap-icons.woff2 -------------------------------------------------------------------------------- /import-tab.js: -------------------------------------------------------------------------------- 1 | let importItems = []; 2 | let existingUrls = new Set(); 3 | 4 | export async function initializeImport() { 5 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 6 | existingUrls = new Set(snapshots.map(e => e.url)); 7 | 8 | // Set default dates for history 9 | const endDate = new Date(); 10 | const startDate = new Date(); 11 | startDate.setDate(startDate.getDate() - 1); // Default to last 24 hours 12 | 13 | document.getElementById('historyStartDate').valueAsDate = startDate; 14 | document.getElementById('historyEndDate').valueAsDate = endDate; 15 | 16 | // Add event listeners 17 | document.getElementById('history-tab').addEventListener('click', loadHistory); 18 | document.getElementById('bookmarks-tab').addEventListener('click', loadBookmarks); 19 | document.getElementById('importFilter').addEventListener('input', filterImportItems); 20 | document.getElementById('showNewOnly').addEventListener('change', filterImportItems); 21 | document.getElementById('selectAll').addEventListener('click', () => toggleAllSelection(true)); 22 | document.getElementById('deselectAll').addEventListener('click', () => toggleAllSelection(false)); 23 | document.getElementById('selectAllHeader').addEventListener('change', e => toggleAllSelection(e.target.checked)); 24 | document.getElementById('importSelected').addEventListener('click', importSelected); 25 | } 26 | 27 | async function loadHistory() { 28 | // request permission to access history 29 | const permission = await chrome.permissions.request({permissions: ['history']}); 30 | if (!permission) { 31 | alert('Permission denied.'); 32 | return; 33 | } 34 | 35 | const startDate = new Date(document.getElementById('historyStartDate').value); 36 | const endDate = new Date(document.getElementById('historyEndDate').value); 37 | endDate.setHours(23, 59, 59, 999); 38 | 39 | if (startDate > endDate) { 40 | alert('Start date must be before end date'); 41 | return; 42 | } 43 | 44 | const maxResults = 10000; 45 | const historyItems = await chrome.history.search({ 46 | text: '', 47 | startTime: startDate.getTime(), 48 | endTime: endDate.getTime(), 49 | maxResults 50 | }); 51 | 52 | importItems = historyItems.map(item => ({ 53 | url: item.url, 54 | title: item.title || '', 55 | timestamp: new Date(item.lastVisitTime).toISOString(), 56 | selected: false, 57 | isNew: !existingUrls.has(item.url) 58 | })); 59 | 60 | renderImportItems(); 61 | } 62 | 63 | async function loadBookmarks() { 64 | // request permission to access bookmarks 65 | const permission = await chrome.permissions.request({permissions: ['bookmarks']}); 66 | if (!permission) { 67 | alert('Permission denied.'); 68 | return; 69 | } 70 | 71 | function processBookmarkTree(nodes) { 72 | let items = []; 73 | for (const node of nodes) { 74 | if (node.url) { 75 | items.push({ 76 | url: node.url, 77 | title: node.title || '', 78 | timestamp: new Date().toISOString(), 79 | selected: false, 80 | isNew: !existingUrls.has(node.url) 81 | }); 82 | } 83 | if (node.children) { 84 | items = items.concat(processBookmarkTree(node.children)); 85 | } 86 | } 87 | return items; 88 | } 89 | 90 | const tree = await chrome.bookmarks.getTree(); 91 | importItems = processBookmarkTree(tree); 92 | renderImportItems(); 93 | } 94 | 95 | function filterImportItems() { 96 | const filterText = document.getElementById('importFilter').value.toLowerCase(); 97 | const showNewOnly = document.getElementById('showNewOnly').checked; 98 | 99 | const tbody = document.getElementById('importTable').querySelector('tbody'); 100 | let visibleCount = 0; 101 | 102 | tbody.querySelectorAll('tr').forEach(row => { 103 | const url = row.querySelector('td:nth-child(2)').textContent; 104 | const title = row.querySelector('td:nth-child(3)').textContent; 105 | const isNew = !existingUrls.has(url); 106 | 107 | const matchesFilter = (url + ' ' + title).toLowerCase().includes(filterText); 108 | const matchesNewOnly = !showNewOnly || isNew; 109 | 110 | if (matchesFilter && matchesNewOnly) { 111 | row.style.display = ''; 112 | visibleCount++; 113 | } else { 114 | row.style.display = 'none'; 115 | } 116 | }); 117 | 118 | updateSelectedCount(); 119 | } 120 | 121 | function toggleAllSelection(selected) { 122 | const tbody = document.getElementById('importTable').querySelector('tbody'); 123 | tbody.querySelectorAll('tr').forEach(row => { 124 | if (row.style.display !== 'none') { 125 | const checkbox = row.querySelector('input[type="checkbox"]'); 126 | checkbox.checked = selected; 127 | const index = parseInt(row.dataset.index); 128 | importItems[index].selected = selected; 129 | } 130 | }); 131 | updateSelectedCount(); 132 | } 133 | 134 | function updateSelectedCount() { 135 | const visibleSelected = Array.from(document.getElementById('importTable').querySelectorAll('tbody tr')) 136 | .filter(row => row.style.display !== 'none' && row.querySelector('input[type="checkbox"]').checked) 137 | .length; 138 | document.getElementById('selectedCount').textContent = visibleSelected; 139 | } 140 | 141 | function renderImportItems() { 142 | const tbody = document.getElementById('importTable').querySelector('tbody'); 143 | tbody.innerHTML = importItems.map((item, index) => ` 144 | 145 | 146 | 149 | 150 | ${item.url} 151 | ${item.title} 152 | ${new Date(item.timestamp).toLocaleString()} 153 | 154 | `).join(''); 155 | 156 | // Add event listeners for checkboxes 157 | tbody.querySelectorAll('input[type="checkbox"]').forEach((checkbox, index) => { 158 | checkbox.addEventListener('change', e => { 159 | importItems[index].selected = e.target.checked; 160 | updateSelectedCount(); 161 | }); 162 | }); 163 | 164 | filterImportItems(); 165 | } 166 | 167 | async function importSelected() { 168 | const selectedItems = importItems.filter(item => item.selected); 169 | if (!selectedItems.length) { 170 | alert('No items selected'); 171 | return; 172 | } 173 | 174 | const tags = document.getElementById('importTags').value 175 | .split(',') 176 | .map(tag => tag.trim()) 177 | .filter(tag => tag); 178 | 179 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 180 | 181 | const newSnapshots = selectedItems.map(item => ({ 182 | id: crypto.randomUUID(), 183 | url: item.url, 184 | title: item.title, 185 | timestamp: new Date().toISOString(), 186 | tags: [...tags], 187 | })); 188 | 189 | snapshots.push(...newSnapshots); 190 | await chrome.storage.local.set({ entries: snapshots }); 191 | 192 | // Update existingUrls 193 | newSnapshots.forEach(snapshot => existingUrls.add(snapshot.url)); 194 | 195 | // Clear selections and re-render 196 | importItems.forEach(item => item.selected = false); 197 | renderImportItems(); 198 | 199 | // Clear tags input 200 | document.getElementById('importTags').value = ''; 201 | 202 | // Show success message 203 | alert(`Successfully imported ${newSnapshots.length} items`); 204 | 205 | // redirect back to the URLs tab 206 | window.location.reload(); 207 | } 208 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "version": "2.1.5", 4 | "name": "ArchiveBox", 5 | "description": "Collect URLs and preserve them using a remote ArchiveBox server", 6 | "permissions": [ 7 | "storage", 8 | "scripting", 9 | "activeTab", 10 | "contextMenus", 11 | "unlimitedStorage" 12 | ], 13 | "optional_permissions": [ 14 | "cookies", 15 | "history", 16 | "bookmarks", 17 | "tabs" 18 | ], 19 | "optional_host_permissions": [ 20 | "" 21 | ], 22 | "icons": { 23 | "16": "16.png", 24 | "32": "32.png", 25 | "48": "48.png", 26 | "128": "128.png" 27 | }, 28 | "action": { 29 | "default_title": "Save to ArchiveBox", 30 | "default_icon": { 31 | "16": "16.png", 32 | "32": "32.png", 33 | "48": "48.png", 34 | "128": "128.png" 35 | } 36 | }, 37 | "options_page": "options.html", 38 | "background": { 39 | "service_worker": "background.js", 40 | "type": "module" 41 | }, 42 | "web_accessible_resources": [{ 43 | "resources": ["popup.css", "popup.js"], 44 | "matches": ["*://*\/*"] 45 | }], 46 | "commands": { 47 | "save-to-archivebox-action": { 48 | "description": "Save URL to ArchiveBox", 49 | "suggested_key": { 50 | "default": "Ctrl+Shift+X", 51 | "mac": "Command+Shift+X" 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ArchiveBox Collector - Options 5 | 6 | 7 | 112 | 113 | 114 | 128 | 129 |
130 | 131 |
132 |
133 |
134 |
135 |
136 | 137 |
138 |
139 | 140 | 141 |
142 |
143 | 146 |
147 |
148 | 149 | 150 |
151 |   152 |
153 | 154 |
155 |     156 |
157 | 158 |
159 |
160 |
161 |
162 |
163 |
164 | 165 |
166 |

Tags

167 |
168 |
169 |
170 |
171 | 172 | 173 | 203 | 204 | 205 |
206 |
207 |
208 |
209 |
210 | 211 |
212 | 214 | 217 | 221 |
222 |
223 | The base URL of your self-hosted ArchiveBox server, e.g. http://localhost:8000
224 | This extension works by sending URLs from the browser to your remote ArchiveBox server via the ArchiveBox REST API. 225 | 226 |
227 |
228 | 229 |
230 | 231 |
232 | 235 | 238 | 242 |
243 |
244 | If the server is running ArchiveBox >= v0.8.5: Log In to your Admin UI & generate an API Token, copy the Token value, set an Expiration Date (REQUIRED), click Save, then paste the value here.
245 | If the server is running ArchiveBox <= v0.7.3: Log In to your Admin UI in this browser every 2 weeks (sorry) and leave this field blank, it will re-use your admin UI login session.
246 | To configure your server to allow submitting URLs without requiring a login or API Token (SECURITY RISK!), run: archivebox config --set PUBLIC_ADD_VIEW=True
247 |
248 |
249 | For detailed instructions and troubleshooting tips see the 🧩 ArchiveBox Extension Setup Guide and 🗄️ ArchiveBox Server Config Documentation. 250 |
251 | 252 |
253 |
254 |
255 |
256 |
257 |
Advanced Users Only: Auto-archive URLs
258 |
259 | 260 | 263 |
264 | When enabled, the extension will automatically archive URLs that match the patterns below. 265 |
266 |
267 |
268 | 269 |
270 | By default sites are only archived when you click ArchiveBox Extension Icon Save to ArchiveBox. 271 |
272 | To archive specific pages automatically whenever they are visited, specify them as a regex here. 273 |
274 | 276 |
277 | To archive all visited pages (not recommended), set this to: .* 278 |
279 |
280 | 281 |
282 | 283 | 285 |
286 | Regex of URLs to never automatically archive (does not prevent adding them manually). 287 |
288 | Exclude sensitive URLs like your email inbox, forms, corporate documents, banking sites, etc. here to avoid accidentally archiving them. 289 |
290 |
291 |

292 |
293 | 294 |
295 | 298 | 302 |
303 |
304 | 305 | 306 |
307 |
308 |
309 |
310 |
311 |
312 | 313 | 314 |
315 |
316 |
317 | 318 |
319 |

Advanced: Logged-in Archiving

320 | For Logged-in Archiving, you must set up one or more Archiving Profiles by importing the credentials you need from a browser. 321 | An Archiving Profile is ArchiveBox's equivalent to a browser profile, it's a set of cookies or login credentials to the websites you want to capture.
322 |
323 | To import cookies from this browser and use them for archiving, click the Export cookies.txt button on the profile you want to use below.

324 |
325 | # save the cookies.txt contents into a text file on your ArchiveBox server, then run:
326 | archivebox config --set COOKIE_FILE=$PWD/cookies.txt
327 | 
328 | # advanced: copy a Chrome user data directory (Profile Path visible on chrome://profile-internals) to the server to use it for logged-in archiving:
329 | archivebox config --set CHROME_USER_DATA_DIR=$PWD/chrome-user-data
330 |
331 |
332 | It's recommended to create dedicated separate accounts for archiving and normal browsing to avoid embedding your personal browsing data + cookies headers into the archives.
333 | e.g. if you normally log in to Twitter as johndoe@example.com, you should not archive with that account, but instead create a new account for archiving like johndoeswitness@example.com. 334 | 335 |
336 | 337 | 338 |
339 |
340 |
Archiving Profile
341 | 344 |   345 |
346 | 349 |
350 |
351 |
352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 |
NameDomainsLast UsedSettingsActions
364 |
365 |
366 |
367 | 368 | 369 |
370 |
371 |
Import Browser Cookies to Archiving Profile
372 | 373 |
374 |
375 |
376 |
377 | 378 | 379 |
380 |
381 | 🔎 382 | 383 |
384 | 387 |
388 | 389 |
390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 |
DomainCookiesExport cookies.txtSelect for archiving
402 |
403 | 404 |
405 |
406 | Selected: 0 407 | 408 | 409 |
410 | 413 |
414 |
415 |
416 |
417 |
418 |
419 | 420 | 421 |
422 |
423 |
424 | 432 | 433 | 434 |
435 |
436 |
437 |
438 | 439 | 440 |
441 |
442 | 443 |
444 | 445 | 446 |
447 |
448 |
449 | From 450 | 451 | To 452 | 453 |
454 |
455 | Tags 456 | 457 | 458 |
459 |
460 | 461 |
462 | 463 | 464 | 465 | 466 | 467 | 468 | 469 | 470 | 471 | 472 |
URLTitleTimestamp
473 |
474 | 475 | 476 |
477 |
478 |
479 |
480 |
481 |
482 | 483 | 512 | 513 | 514 | 515 | 516 | 520 | 521 | 522 | 523 | -------------------------------------------------------------------------------- /options.js: -------------------------------------------------------------------------------- 1 | import { initializeSnapshotsTab } from './snapshots-tab.js'; 2 | import { initializeImport } from './import-tab.js'; 3 | import { initializePersonasTab } from './personas-tab.js'; 4 | import { initializeCookiesTab } from './cookies-tab.js'; 5 | import { initializeConfigTab } from './config-tab.js'; 6 | 7 | // Initialize all tabs when options page loads 8 | document.addEventListener('DOMContentLoaded', () => { 9 | initializeSnapshotsTab(); 10 | initializeImport(); 11 | initializePersonasTab(); 12 | initializeCookiesTab(); 13 | initializeConfigTab(); 14 | 15 | function changeTab() { 16 | if (window.location.hash && window.location.hash !== document.querySelector('a.nav-link.active').id) { 17 | console.log('Changing tab based on URL hash:', window.location.hash, `a.nav-link${window.location.hash}`, document.querySelector(`a.nav-link${window.location.hash}`)); 18 | // document.querySelector(`a.nav-link${window.location.hash}`).click(); 19 | } 20 | } 21 | // changeTab(); 22 | window.addEventListener('hashchange', changeTab); 23 | 24 | var tabEls = document.querySelectorAll('a.nav-link[data-bs-toggle="tab"]') 25 | for (const tabEl of tabEls) { 26 | tabEl.addEventListener('shown.bs.tab', function (event) { 27 | console.log('ArchiveBox tab switched to:', event.target); 28 | event.target // newly activated tab 29 | event.relatedTarget // previous active tab 30 | // window.location.hash = event.target.id; 31 | }) 32 | } 33 | }); 34 | -------------------------------------------------------------------------------- /personas-tab.js: -------------------------------------------------------------------------------- 1 | let currentPersonas = []; 2 | 3 | async function detectCurrentSettings(personaId) { 4 | const {personas} = await chrome.storage.local.get('personas'); 5 | const persona = personas.find(p => p.id === personaId); 6 | 7 | console.log('Updating settings for profile:', personaId, persona.settings); 8 | 9 | const settings = { 10 | userAgent: persona.settings.userAgent || navigator.userAgent, 11 | language: persona.settings.language || navigator.language, 12 | timezone: persona.settings.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, 13 | viewport: persona.settings.viewport || `${window.innerWidth}x${window.innerHeight}`, 14 | operatingSystem: persona.settings.operatingSystem || detectOS(), 15 | geography: persona.settings.geography || await detectGeography() 16 | }; 17 | 18 | persona.settings = settings; 19 | 20 | await chrome.storage.local.set({ personas }); 21 | await loadPersonas(); 22 | return settings; 23 | } 24 | 25 | function detectOS() { 26 | const ua = navigator.userAgent; 27 | if (ua.includes('Windows')) return 'Windows'; 28 | if (ua.includes('Mac OS X')) return 'macOS'; 29 | if (ua.includes('Linux')) return 'Linux'; 30 | if (ua.includes('Android')) return 'Android'; 31 | if (ua.includes('iOS')) return 'iOS'; 32 | return 'Unknown'; 33 | } 34 | 35 | async function detectGeography() { 36 | try { 37 | const response = await fetch('https://ipapi.co/json/'); 38 | const data = await response.json(); 39 | return `${data.city}, ${data.country_name}`; 40 | } catch (error) { 41 | console.error('Failed to detect geography:', error); 42 | return 'Unknown'; 43 | } 44 | } 45 | 46 | async function removePersonaDomain(personaId, domain) { 47 | const persona = currentPersonas.find(p => p.id === personaId); 48 | if (!persona) return; 49 | delete persona.cookies[domain]; 50 | await chrome.storage.local.set({ personas: currentPersonas }); 51 | await loadPersonas(); 52 | } 53 | 54 | async function loadPersonas() { 55 | let { personas = [], activePersona = '' } = await chrome.storage.local.get(['personas', 'activePersona']); 56 | currentPersonas = personas; 57 | 58 | // if no personas exist, create a default one 59 | if (!personas || personas.length === 0) { 60 | createNewPersona('Private'); 61 | createNewPersona('Work'); 62 | createNewPersona('Anonymous'); 63 | ({ personas, activePersona } = await chrome.storage.local.get(['personas', 'activePersona'])); 64 | } 65 | 66 | if (!activePersona) { 67 | await chrome.storage.local.set({ activePersona: currentPersonas[0].id }); 68 | activePersona = currentPersonas[0].id; 69 | } 70 | window.activePersona = activePersona; 71 | window.personas = personas; 72 | 73 | // Update persona selector 74 | const select = document.getElementById('activePersona'); 75 | select.innerHTML = ` 76 | 77 | ${(personas || []).map(p => ` 78 | 81 | `).join('')} 82 | `; 83 | 84 | // Update persona table 85 | const tbody = document.getElementById('personaTable').querySelector('tbody'); 86 | tbody.innerHTML = (personas || []).map(p => ` 87 | 88 | 89 | 91 | 92 | 93 | ${Object.keys(p.cookies || {}).length} domains
94 | ${Object.keys(p.cookies || {}).map(domain => ``).join(' ')} 95 | 96 | ${p.lastUsed ? new Date(p.lastUsed).toLocaleString() : 'Never'} 97 | 98 |
99 |
100 | 101 | 103 |
104 |
105 | 106 | 108 |
109 |
110 | 111 | 113 |
114 |
115 | 116 | 118 |
119 |
120 | 121 | 123 |
124 |
125 | 126 | 128 |
129 |
130 | 131 | 132 |
133 | 136 | 139 | 142 | 145 |
146 | 147 | 148 | `).join(''); 149 | 150 | // Add change detection for settings 151 | tbody.querySelectorAll('tr').forEach(row => { 152 | const saveBtn = row.querySelector('.save-settings'); 153 | const inputs = row.querySelectorAll('input'); 154 | 155 | inputs.forEach(input => { 156 | input.addEventListener('input', () => { 157 | const hasChanges = Array.from(inputs).some(input => { 158 | const originalValue = input.dataset.original || ''; 159 | return input.value !== originalValue; 160 | }); 161 | saveBtn.style.display = hasChanges ? 'block' : 'none'; 162 | }); 163 | }); 164 | }); 165 | 166 | // add event listener for remove-persona-domain 167 | document.querySelectorAll('.remove-persona-domain').forEach(button => { 168 | button.addEventListener('click', () => { 169 | removePersonaDomain(button.dataset.personaId, button.dataset.personaDomain); 170 | }); 171 | }); 172 | 173 | // add event listener for detect-settings 174 | document.querySelectorAll('.detect-settings').forEach(button => { 175 | button.addEventListener('click', async () => { 176 | await detectCurrentSettings(button.dataset.id); 177 | }); 178 | }); 179 | 180 | // Update stats for active persona 181 | updatePersonaStats(activePersona); 182 | 183 | // Update import button state 184 | document.getElementById('importCookies').disabled = !activePersona; 185 | } 186 | window.loadPersonas = loadPersonas; 187 | 188 | async function createNewPersona(default_name) { 189 | const name = default_name || prompt('Enter name for new profile:'); 190 | if (!name) return; 191 | 192 | const persona = { 193 | id: crypto.randomUUID(), 194 | name, 195 | created: new Date().toISOString(), 196 | lastUsed: null, 197 | cookies: {}, 198 | settings: {}, 199 | }; 200 | 201 | currentPersonas.push(persona); 202 | await chrome.storage.local.set({ personas: currentPersonas }); 203 | await loadPersonas(); 204 | const settings = await detectCurrentSettings(persona.id); 205 | persona.settings = settings; 206 | await loadPersonas(); 207 | } 208 | 209 | async function savePersonaSettings(id) { 210 | const persona = currentPersonas.find(p => p.id === id); 211 | if (!persona) return; 212 | 213 | const row = document.querySelector(`tr[data-id="${id}"]`); 214 | if (!row) return; 215 | 216 | // Update name 217 | const nameInput = row.querySelector('.persona-name'); 218 | persona.name = nameInput.value; 219 | nameInput.dataset.original = nameInput.value; 220 | 221 | // Update settings 222 | persona.settings = persona.settings || {}; 223 | row.querySelectorAll('.persona-setting').forEach(input => { 224 | persona.settings[input.dataset.setting] = input.value; 225 | input.dataset.original = input.value; 226 | }); 227 | 228 | await chrome.storage.local.set({ personas: currentPersonas }); 229 | row.querySelector('.save-settings').style.display = 'none'; 230 | 231 | // Refresh UI 232 | await loadPersonas(); 233 | } 234 | 235 | async function updatePersonaStats(personaId) { 236 | const stats = document.getElementById('personaStats'); 237 | if (!personaId) { 238 | stats.textContent = 'No active profile selected'; 239 | return; 240 | } 241 | 242 | const persona = currentPersonas.find(p => p.id === personaId); 243 | if (!persona) return; 244 | 245 | const domainCount = Object.keys(persona.cookies || {}).length; 246 | const cookieCount = Object.values(persona.cookies || {}).reduce((sum, cookies) => sum + cookies.length, 0); 247 | 248 | stats.innerHTML = `${domainCount} domains
${cookieCount} cookies`; 249 | } 250 | 251 | async function deletePersona(id) { 252 | if (!confirm('Delete this profile? This cannot be undone.')) return; 253 | 254 | currentPersonas = currentPersonas.filter(p => p.id !== id); 255 | const { activePersona } = await chrome.storage.local.get('activePersona'); 256 | 257 | if (activePersona === id) { 258 | await chrome.storage.local.set({ activePersona: '' }); 259 | } 260 | 261 | await chrome.storage.local.set({ personas: currentPersonas }); 262 | await loadPersonas(); 263 | } 264 | 265 | async function setActivePersona(id) { 266 | await chrome.storage.local.set({ activePersona: id }); 267 | document.getElementById('importCookies').disabled = !id; 268 | await loadPersonas(); 269 | } 270 | 271 | // Using formatCookiesForExport from utils.js 272 | 273 | async function exportPersonaCookies(id) { 274 | const persona = currentPersonas.find(p => p.id === id); 275 | if (!persona) return; 276 | 277 | const text = formatCookiesForExport(persona.cookies); 278 | await navigator.clipboard.writeText(text); 279 | alert(`${Object.keys(persona.cookies).length} domain logins (${Object.values(persona.cookies).reduce((sum, cookies) => sum + cookies.length, 0)} cookies) copied to clipboard for "${persona.name}"! Save them into cookies.txt on your ArchiveBox server and run: archivebox config --set COOKIES_FILE=/path/to/cookies.txt`); 280 | } 281 | 282 | import { formatCookiesForExport } from './utils.js'; 283 | 284 | export function initializePersonasTab() { 285 | // Persona management 286 | document.getElementById('newPersona').addEventListener('click', createNewPersona); 287 | document.getElementById('activePersona').addEventListener('change', e => setActivePersona(e.target.value)); 288 | 289 | document.getElementById('personaTable').addEventListener('click', async e => { 290 | const button = e.target.closest('button'); 291 | if (!button) return; 292 | 293 | const id = button.dataset.id; 294 | if (button.classList.contains('export-cookies')) { 295 | await exportPersonaCookies(id); 296 | } else if (button.classList.contains('delete-persona')) { 297 | await deletePersona(id); 298 | } else if (button.classList.contains('save-settings')) { 299 | await savePersonaSettings(id); 300 | } 301 | }); 302 | 303 | 304 | // Load initial data 305 | loadPersonas(); 306 | } 307 | -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | class Snapshot { 2 | constructor(url, tags = [], title = '', favIconUrl = null) { 3 | this.id = crypto.randomUUID(); 4 | this.url = url; 5 | this.timestamp = new Date().toISOString(); 6 | this.tags = tags; 7 | this.title = title; 8 | this.favIconUrl = favIconUrl; 9 | } 10 | } 11 | 12 | const IS_IN_POPUP = window.location.href.startsWith('chrome-extension://') && window.location.href.endsWith('/popup.html'); 13 | const IS_ON_WEBSITE = !window.location.href.startsWith('chrome-extension://'); 14 | 15 | window.popup_element = null; // Global reference to popup element 16 | window.hide_timer = null; 17 | 18 | window.closePopup = function () { 19 | document.querySelector(".archive-box-iframe")?.remove(); 20 | window.popup_element = null; 21 | console.debug("Closed ArchiveBox popup"); 22 | }; 23 | 24 | // Handle escape key when popup doesn't have focus 25 | document.addEventListener('keydown', (e) => { 26 | if (e.key == 'Escape') { 27 | closePopup(); 28 | } 29 | }); 30 | 31 | async function getAllTags() { 32 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 33 | return [...new Set(snapshots.flatMap(snapshot => snapshot.tags))] 34 | .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); 35 | } 36 | 37 | async function sendToArchiveBox(url, tags) { 38 | let status = "pending"; 39 | let ok = false; 40 | 41 | try { 42 | console.log('i Sending to ArchiveBox', { url, tags }); 43 | await new Promise((resolve, reject) => { 44 | chrome.runtime.sendMessage({ 45 | type: 'archivebox_add', 46 | body: JSON.stringify({ 47 | urls: [url], 48 | tags: tags, 49 | }) 50 | }, (response) => { 51 | if (!response.ok) { 52 | reject(`${response.errorMessage}`); 53 | } 54 | resolve(response); 55 | }); 56 | }) 57 | 58 | ok = true; 59 | status = 'Saved to ArchiveBox Server' 60 | } catch (error) { 61 | console.log(`ArchiveBox request failed: ${error}`); 62 | ok = false; 63 | status = `Failed to archive: ${error}` 64 | } 65 | 66 | const status_div = popup_element.querySelector('small'); 67 | status_div.innerHTML = ` 68 | 69 | ${status} 70 | `; 71 | } 72 | 73 | window.getCurrentSnapshot = async function() { 74 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 75 | let current_snapshot = snapshots.find(snapshot => snapshot.url === window.location.href); 76 | 77 | if (!current_snapshot) { 78 | current_snapshot = new Snapshot(String(window.location.href), [], document.title); 79 | snapshots.push(current_snapshot); 80 | await chrome.storage.local.set({ entries: snapshots }); 81 | } 82 | 83 | console.log('i Loaded current ArchiveBox snapshot', current_snapshot); 84 | return { current_snapshot, snapshots }; // Return both for atomic updates 85 | } 86 | 87 | window.getSuggestedTags = async function() { 88 | const { current_snapshot, snapshots } = await getCurrentSnapshot(); 89 | // Get all unique tags sorted by recency, excluding the current snapshot's tags 90 | return ['⭐️', ...new Set( 91 | [ 92 | window.location.hostname.replace('www.', '').replace('.com', ''), 93 | ...snapshots 94 | .filter(snapshot => snapshot.url !== current_snapshot.url) // Better way to exclude current 95 | .reverse() 96 | .flatMap(snapshot => snapshot.tags), 97 | ] 98 | )] 99 | .filter(tag => !current_snapshot.tags.includes(tag)) 100 | .slice(0, 5); 101 | } 102 | 103 | window.updateSuggestions = async function() { 104 | // console.log('Getting tag suggestions'); 105 | if (!popup_element) return 106 | const suggestions_div = popup_element.querySelector('.ARCHIVEBOX__tag-suggestions'); 107 | const suggested_tags = await getSuggestedTags(); 108 | // console.log('Got suggestions', suggested_tags); 109 | suggestions_div.innerHTML = suggested_tags.length 110 | ? `${suggested_tags 111 | .map(tag => `${tag}`) 112 | .join(' ')}` 113 | : ''; 114 | } 115 | 116 | window.updateCurrentTags = async function() { 117 | if (!popup_element) return; 118 | const current_tags_div = popup_element.querySelector('.ARCHIVEBOX__current-tags'); 119 | const status_div = popup_element.querySelector('small'); 120 | const { current_snapshot } = await getCurrentSnapshot(); 121 | 122 | current_tags_div.innerHTML = current_snapshot.tags.length 123 | ? `${current_snapshot.tags 124 | .map(tag => `${tag}`) 125 | .join(' ')}` 126 | : ''; 127 | 128 | // Add click handlers for removing tags 129 | current_tags_div.querySelectorAll('.ARCHIVEBOX__tag-badge.current').forEach(badge => { 130 | badge.addEventListener('click', async (e) => { 131 | if (e.target.classList.contains('current')) { 132 | const { current_snapshot, snapshots } = await getCurrentSnapshot(); 133 | const tag_to_remove = e.target.dataset.tag; 134 | current_snapshot.tags = current_snapshot.tags.filter(tag => tag !== tag_to_remove); 135 | await chrome.storage.local.set({ entries: snapshots }); 136 | await updateCurrentTags(); 137 | await updateSuggestions(); 138 | } 139 | }); 140 | }); 141 | 142 | sendToArchiveBox(current_snapshot.url, current_snapshot.tags); 143 | } 144 | 145 | 146 | window.createPopup = async function() { 147 | const { current_snapshot } = await getCurrentSnapshot(); 148 | 149 | // Create iframe container 150 | document.querySelector('.archive-box-iframe')?.remove(); 151 | const iframe = document.createElement('iframe'); 152 | iframe.className = 'archive-box-iframe'; 153 | 154 | // Set iframe styles for positioning 155 | Object.assign(iframe.style, { 156 | position: 'fixed', 157 | top: '20px', 158 | right: '20px', 159 | zIndex: '2147483647', 160 | background: 'transparent', 161 | borderRadius: '6px', 162 | border: '0px', 163 | margin: '0px', 164 | padding: '0px', 165 | transform: 'translateY(0px)', 166 | boxSizing: 'border-box', 167 | width: '550px', // Initial width 168 | height: '200px', // Initial height 169 | transition: 'height 0.2s ease-out', // Smooth height transitions 170 | display: 'block', 171 | }); 172 | 173 | document.body.appendChild(iframe); 174 | 175 | // Function to resize iframe based on content 176 | function resizeIframe() { 177 | const doc = iframe.contentDocument || iframe.contentWindow.document; 178 | const content = doc.querySelector('.archive-box-popup'); 179 | if (content) { 180 | const height = content.offsetHeight; 181 | const dropdown = doc.querySelector('.ARCHIVEBOX__autocomplete-dropdown'); 182 | const dropdownHeight = dropdown && dropdown.style.display !== 'none' ? dropdown.offsetHeight : 0; 183 | iframe.style.height = (height + dropdownHeight + 20) + 'px'; // Add padding 184 | } 185 | } 186 | 187 | // Create popup content inside iframe 188 | const doc = iframe.contentDocument || iframe.contentWindow.document; 189 | 190 | // Add styles to iframe 191 | const style = doc.createElement('style'); 192 | style.textContent = ` 193 | html, body { 194 | margin: 0; 195 | padding: 0; 196 | font-family: system-ui, -apple-system, sans-serif; 197 | font-size: 16px; 198 | width: 100%; 199 | height: auto; 200 | overflow: visible; 201 | } 202 | 203 | .archive-box-popup { 204 | border-radius: 13px; 205 | min-height: 90px; 206 | background: #bf7070; 207 | margin: 0px; 208 | padding: 6px; 209 | padding-top: 8px; 210 | color: white; 211 | box-shadow: 0 2px 10px rgba(0,0,0,0.1); 212 | font-family: system-ui, -apple-system, sans-serif; 213 | transition: all 0.2s ease-out; 214 | } 215 | 216 | .archive-box-popup:hover { 217 | animation: slideDown -0.3s ease-in-out forwards; 218 | opacity: 1; 219 | } 220 | 221 | .archive-box-popup small { 222 | display: block; 223 | width: 100%; 224 | text-align: center; 225 | margin-top: 5px; 226 | color: #fefefe; 227 | overflow: hidden; 228 | font-size: 11px; 229 | opacity: 1.0; 230 | } 231 | 232 | .archive-box-popup small.fade-out { 233 | animation: fadeOut 10s ease-in-out forwards; 234 | } 235 | 236 | .archive-box-popup img { 237 | width: 15%; 238 | max-width: 40px; 239 | display: inline-block; 240 | vertical-align: top; 241 | } 242 | 243 | .archive-box-popup .options-link { 244 | border: 1px solid #00000026; 245 | border-right: 0px; 246 | margin-right: -9px; 247 | margin-top: -1px; 248 | border-radius: 6px 0px 0px 6px; 249 | padding-right: 7px; 250 | padding-left: 3px; 251 | text-decoration: none; 252 | text-align: center; 253 | font-size: 24px; 254 | line-height: 1.4; 255 | display: inline-block; 256 | width: 34px; 257 | transition: text-shadow 0.1s ease-in-out; 258 | } 259 | .archive-box-popup a.options-link:hover { 260 | text-shadow: 0 0 10px #a1a1a1; 261 | } 262 | 263 | .archive-box-popup .metadata { 264 | display: inline-block; 265 | max-width: 80%; 266 | overflow: hidden; 267 | text-overflow: ellipsis; 268 | white-space: nowrap; 269 | } 270 | 271 | .archive-box-popup input { 272 | width: calc(100% - 42px); 273 | border: 0px; 274 | margin: 0px; 275 | padding: 5px; 276 | padding-left: 13px; 277 | border-radius: 6px; 278 | min-width: 100px; 279 | background-color: #fefefe; 280 | color: #1a1a1a; 281 | vertical-align: top; 282 | display: inline-block; 283 | line-height: 1.75 !important; 284 | margin-bottom: 8px; 285 | } 286 | 287 | @keyframes fadeOut { 288 | 0% { opacity: 1; } 289 | 80% { opacity: 0.8;} 290 | 100% { opacity: 0; display: none; } 291 | } 292 | 293 | @keyframes slideDown { 294 | 0% { top: -500px; } 295 | 100% { top: 20px } 296 | } 297 | 298 | .ARCHIVEBOX__tag-suggestions { 299 | margin-top: 20px; 300 | display: inline; 301 | min-height: 0; 302 | background-color: rgba(0, 0, 0, 0); 303 | border: 0; 304 | box-shadow: 0 0 0 0; 305 | } 306 | .ARCHIVEBOX__current-tags { 307 | display: inline; 308 | } 309 | 310 | .current-tags { 311 | margin-top: 20px; 312 | display: inline; 313 | } 314 | 315 | .ARCHIVEBOX__tag-badge { 316 | display: inline-block; 317 | background: #e9ecef; 318 | padding: 3px 8px; 319 | border-radius: 3px; 320 | padding-left: 18px; 321 | margin: 2px; 322 | font-size: 15px; 323 | cursor: pointer; 324 | user-select: none; 325 | } 326 | 327 | .ARCHIVEBOX__tag-badge.suggestion { 328 | background: #007bff; 329 | color: white; 330 | opacity: 0.2; 331 | } 332 | .ARCHIVEBOX__tag-badge.suggestion:hover { 333 | opacity: 0.8; 334 | } 335 | .ARCHIVEBOX__tag-badge.suggestion:active { 336 | opacity: 1; 337 | } 338 | 339 | .ARCHIVEBOX__tag-badge.suggestion:after { 340 | content: ' +'; 341 | } 342 | 343 | .ARCHIVEBOX__tag-badge.current { 344 | background: #007bff; 345 | color: #ddd; 346 | position: relative; 347 | padding-right: 20px; 348 | } 349 | 350 | .ARCHIVEBOX__tag-badge.current:hover::after { 351 | content: '×'; 352 | position: absolute; 353 | right: 5px; 354 | top: 50%; 355 | transform: translateY(-50%); 356 | font-weight: bold; 357 | cursor: pointer; 358 | } 359 | 360 | .status-indicator { 361 | display: inline-block; 362 | width: 8px; 363 | height: 8px; 364 | border-radius: 50%; 365 | margin-right: 5px; 366 | } 367 | 368 | .status-indicator.success { 369 | background: #28a745; 370 | } 371 | 372 | .status-indicator.error { 373 | background: #dc3545; 374 | } 375 | 376 | .ARCHIVEBOX__autocomplete-dropdown { 377 | background: white; 378 | border: 1px solid #ddd; 379 | border-radius: 0 0 6px 6px; 380 | box-shadow: 0 2px 4px rgba(0,0,0,0.1); 381 | max-height: 200px; 382 | overflow-y: auto; 383 | transition: all 0.2s ease-out; 384 | } 385 | 386 | .ARCHIVEBOX__autocomplete-item { 387 | padding: 8px 12px; 388 | cursor: pointer; 389 | color: #333; 390 | } 391 | 392 | .ARCHIVEBOX__autocomplete-item:hover, 393 | .ARCHIVEBOX__autocomplete-item.selected { 394 | background: #f0f0f0; 395 | } 396 | `; 397 | doc.head.appendChild(style); 398 | 399 | // Create popup content 400 | const popup = doc.createElement('div'); 401 | popup.className = 'archive-box-popup'; 402 | popup.innerHTML = ` 403 | 🏛️ 404 |
405 |

406 | 407 | 408 | Saved locally... 409 | 410 | `; 411 | 412 | doc.body.appendChild(popup); 413 | window.popup_element = popup; 414 | 415 | // Add message passing for options link 416 | popup.querySelector('.options-link').addEventListener('click', (e) => { 417 | e.preventDefault(); 418 | chrome.runtime.sendMessage({ action: 'openOptionsPage', id: current_snapshot.id }); 419 | }); 420 | 421 | const input = popup.querySelector('input'); 422 | const suggestions_div = popup.querySelector('.ARCHIVEBOX__tag-suggestions'); 423 | const current_tags_div = popup.querySelector('.ARCHIVEBOX__current-tags'); 424 | 425 | // console.log('Getting current tags and suggestions'); 426 | 427 | // Initial display of current tags and suggestions 428 | await window.updateCurrentTags(); 429 | await window.updateSuggestions(); 430 | 431 | // Add click handlers for suggestion badges 432 | suggestions_div.addEventListener('click', async (e) => { 433 | if (e.target.classList.contains('suggestion')) { 434 | const { current_snapshot, snapshots } = await getCurrentSnapshot(); 435 | const tag = e.target.textContent.replace(' +', ''); 436 | if (!current_snapshot.tags.includes(tag)) { 437 | current_snapshot.tags.push(tag); 438 | await chrome.storage.local.set({ entries: snapshots }); 439 | await updateCurrentTags(); 440 | await updateSuggestions(); 441 | } 442 | } 443 | }); 444 | current_tags_div.addEventListener('click', async (e) => { 445 | if (e.target.classList.contains('current')) { 446 | const tag = e.target.dataset.tag; 447 | console.log('Removing tag', tag); 448 | const { current_snapshot, snapshots } = await getCurrentSnapshot(); 449 | current_snapshot.tags = current_snapshot.tags.filter(t => t !== tag); 450 | await chrome.storage.local.set({ entries: snapshots }); 451 | await updateCurrentTags(); 452 | await updateSuggestions(); 453 | } 454 | }); 455 | 456 | // Add dropdown container 457 | const dropdownContainer = document.createElement('div'); 458 | dropdownContainer.className = 'ARCHIVEBOX__autocomplete-dropdown'; 459 | dropdownContainer.style.display = 'none'; 460 | input.parentNode.insertBefore(dropdownContainer, input.nextSibling); 461 | 462 | let selectedIndex = -1; 463 | let filteredTags = []; 464 | 465 | async function updateDropdown() { 466 | const inputValue = input.value.toLowerCase(); 467 | const allTags = await getAllTags(); 468 | 469 | // Filter tags that match input and aren't already used 470 | const { current_snapshot } = await getCurrentSnapshot(); 471 | filteredTags = allTags 472 | .filter(tag => 473 | tag.toLowerCase().includes(inputValue) && 474 | !current_snapshot.tags.includes(tag) && 475 | inputValue 476 | ) 477 | .slice(0, 5); // Limit to 5 suggestions 478 | 479 | if (filteredTags.length === 0) { 480 | dropdownContainer.style.display = 'none'; 481 | selectedIndex = -1; 482 | } else { 483 | dropdownContainer.innerHTML = filteredTags 484 | .map((tag, index) => ` 485 |
487 | ${tag} 488 |
489 | `) 490 | .join(''); 491 | 492 | dropdownContainer.style.display = 'block'; 493 | } 494 | 495 | // Trigger resize after dropdown visibility changes 496 | setTimeout(resizeIframe, 0); 497 | } 498 | 499 | // Handle input changes 500 | input.addEventListener('input', updateDropdown); 501 | 502 | // Handle keyboard navigation 503 | 504 | // handle escape key when popup has focus 505 | input.addEventListener("keydown", async (e) => { 506 | if (e.key === "Escape") { 507 | e.stopPropagation(); 508 | dropdownContainer.style.display = "none"; 509 | selectedIndex = -1; 510 | closePopup(); 511 | return; 512 | } 513 | 514 | if (!filteredTags.length) { 515 | if (e.key === 'Enter' && input.value.trim()) { 516 | e.preventDefault(); 517 | const { current_snapshot, snapshots } = await getCurrentSnapshot(); 518 | const newTag = input.value.trim(); 519 | if (!current_snapshot.tags.includes(newTag)) { 520 | current_snapshot.tags.push(newTag); 521 | await chrome.storage.local.set({ entries: snapshots }); 522 | input.value = ''; 523 | await updateCurrentTags(); 524 | await updateSuggestions(); 525 | } 526 | } 527 | return; 528 | } 529 | 530 | switch (e.key) { 531 | case 'ArrowDown': 532 | e.preventDefault(); 533 | selectedIndex = Math.min(selectedIndex + 1, filteredTags.length - 1); 534 | updateDropdown(); 535 | break; 536 | 537 | case 'ArrowUp': 538 | e.preventDefault(); 539 | selectedIndex = Math.max(selectedIndex - 1, -1); 540 | updateDropdown(); 541 | break; 542 | 543 | case 'Enter': 544 | e.preventDefault(); 545 | if (selectedIndex >= 0) { 546 | const selectedTag = filteredTags[selectedIndex]; 547 | const { current_snapshot, snapshots } = await getCurrentSnapshot(); 548 | if (!current_snapshot.tags.includes(selectedTag)) { 549 | current_snapshot.tags.push(selectedTag); 550 | await chrome.storage.local.set({ entries: snapshots}); 551 | } 552 | input.value = ''; 553 | dropdownContainer.style.display = 'none'; 554 | selectedIndex = -1; 555 | await updateCurrentTags(); 556 | await updateSuggestions(); 557 | } 558 | break; 559 | 560 | case 'Tab': 561 | if (selectedIndex >= 0) { 562 | e.preventDefault(); 563 | input.value = filteredTags[selectedIndex]; 564 | dropdownContainer.style.display = 'none'; 565 | selectedIndex = -1; 566 | } 567 | break; 568 | } 569 | }); 570 | 571 | 572 | // Handle click selection 573 | dropdownContainer.addEventListener('click', async (e) => { 574 | const item = e.target.closest('.ARCHIVEBOX__autocomplete-item'); 575 | if (item) { 576 | const selectedTag = item.dataset.tag; 577 | const { current_snapshot, snapshots } = await getCurrentSnapshot(); 578 | if (!current_snapshot.tags.includes(selectedTag)) { 579 | current_snapshot.tags.push(selectedTag); 580 | await chrome.storage.local.set({ entries: snapshots }); 581 | } 582 | input.value = ''; 583 | dropdownContainer.style.display = 'none'; 584 | selectedIndex = -1; 585 | await updateCurrentTags(); 586 | await updateSuggestions(); 587 | } 588 | }); 589 | 590 | // Hide dropdown when clicking outside 591 | document.addEventListener('click', (e) => { 592 | if (!e.target.closest('.ARCHIVEBOX__autocomplete-dropdown') && 593 | !e.target.closest('input')) { 594 | dropdownContainer.style.display = 'none'; 595 | selectedIndex = -1; 596 | } 597 | }); 598 | 599 | input.focus(); 600 | console.log('+ Showed ArchiveBox popup in iframe'); 601 | 602 | // Add resize triggers 603 | const resizeObserver = new ResizeObserver(() => { 604 | resizeIframe(); 605 | }); 606 | 607 | // Observe the popup content for size changes 608 | resizeObserver.observe(popup); 609 | 610 | const originalUpdateCurrentTags = window.updateCurrentTags; 611 | window.updateCurrentTags = async function() { 612 | await originalUpdateCurrentTags(); 613 | resizeIframe(); 614 | } 615 | 616 | async function updateDropdown() { 617 | const inputValue = input.value.toLowerCase(); 618 | const allTags = await getAllTags(); 619 | 620 | // Filter tags that match input and aren't already used 621 | const { current_snapshot } = await getCurrentSnapshot(); 622 | filteredTags = allTags 623 | .filter(tag => 624 | tag.toLowerCase().includes(inputValue) && 625 | !current_snapshot.tags.includes(tag) && 626 | inputValue 627 | ) 628 | .slice(0, 5); // Limit to 5 suggestions 629 | 630 | if (filteredTags.length === 0) { 631 | dropdownContainer.style.display = 'none'; 632 | selectedIndex = -1; 633 | } else { 634 | dropdownContainer.innerHTML = filteredTags 635 | .map((tag, index) => ` 636 |
638 | ${tag} 639 |
640 | `) 641 | .join(''); 642 | 643 | dropdownContainer.style.display = 'block'; 644 | } 645 | 646 | // Trigger resize after dropdown visibility changes 647 | setTimeout(resizeIframe, 0); 648 | } 649 | 650 | // Initial resize 651 | setTimeout(resizeIframe, 0); 652 | } 653 | 654 | window.createPopup(); 655 | -------------------------------------------------------------------------------- /snapshots-tab.js: -------------------------------------------------------------------------------- 1 | import { filterSnapshots, addToArchiveBox, downloadCsv, downloadJson, updateStatusIndicator, getArchiveBoxServerUrl } from './utils.js'; 2 | 3 | export function initializeSnapshotsTab() { 4 | let selectedSnapshots = new Set(); 5 | let filteredTags = []; 6 | let selectedTagIndex = -1; 7 | 8 | // Initialize tag autocomplete and modal functionality 9 | async function initializeTagModal() { 10 | const modal = document.getElementById('editTagsModal'); 11 | const input = document.getElementById('addTagInput'); 12 | const dropdown = document.getElementById('tagAutocomplete'); 13 | const currentTagsList = document.getElementById('currentTagsList'); 14 | 15 | // Update current tags whenever modal is shown 16 | modal.addEventListener('show.bs.modal', async () => { 17 | await updateCurrentTagsList(); 18 | input.value = ''; 19 | dropdown.style.display = 'none'; 20 | }); 21 | 22 | // Handle tag input with autocomplete 23 | input.addEventListener('input', async () => { 24 | const inputValue = input.value.toLowerCase().trim(); 25 | if (!inputValue) { 26 | dropdown.style.display = 'none'; 27 | return; 28 | } 29 | 30 | const allTags = await getAllUniqueTags(); 31 | const currentTags = getCurrentModalTags(); 32 | 33 | // Filter tags that match input and aren't already used 34 | filteredTags = allTags 35 | .filter(tag => 36 | tag.toLowerCase().includes(inputValue) && 37 | !currentTags.includes(tag) 38 | ) 39 | .slice(0, 5); // Limit to 5 suggestions 40 | 41 | if (filteredTags.length === 0) { 42 | dropdown.style.display = 'none'; 43 | } else { 44 | dropdown.innerHTML = filteredTags 45 | .map((tag, index) => ` 46 | 50 | `) 51 | .join(''); 52 | dropdown.style.display = 'block'; 53 | } 54 | }); 55 | 56 | // Handle keyboard navigation 57 | input.addEventListener('keydown', async (e) => { 58 | if (e.key === 'Escape') { 59 | dropdown.style.display = 'none'; 60 | selectedTagIndex = -1; 61 | return; 62 | } 63 | 64 | if (e.key === 'Enter') { 65 | e.preventDefault(); 66 | if (selectedTagIndex >= 0 && filteredTags.length > 0) { 67 | await addTagToModal(filteredTags[selectedTagIndex]); 68 | } else if (input.value.trim()) { 69 | await addTagToModal(input.value.trim()); 70 | } 71 | input.value = ''; 72 | dropdown.style.display = 'none'; 73 | selectedTagIndex = -1; 74 | return; 75 | } 76 | 77 | if (!filteredTags.length) return; 78 | 79 | switch (e.key) { 80 | case 'ArrowDown': 81 | e.preventDefault(); 82 | selectedTagIndex = Math.min(selectedTagIndex + 1, filteredTags.length - 1); 83 | updateDropdownSelection(); 84 | break; 85 | 86 | case 'ArrowUp': 87 | e.preventDefault(); 88 | selectedTagIndex = Math.max(selectedTagIndex - 1, -1); 89 | updateDropdownSelection(); 90 | break; 91 | } 92 | }); 93 | 94 | // Handle click selection in dropdown 95 | dropdown.addEventListener('click', async (e) => { 96 | const item = e.target.closest('.dropdown-item'); 97 | if (item) { 98 | await addTagToModal(item.dataset.tag); 99 | input.value = ''; 100 | dropdown.style.display = 'none'; 101 | selectedTagIndex = -1; 102 | } 103 | }); 104 | 105 | // Save changes button 106 | document.getElementById('saveTagChanges').addEventListener('click', async () => { 107 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 108 | const newTags = getCurrentModalTags(); 109 | 110 | // Update tags for all selected snapshots 111 | snapshots.forEach(snapshot => { 112 | if (selectedSnapshots.has(snapshot.id)) { 113 | snapshot.tags = [...newTags]; 114 | } 115 | }); 116 | 117 | await chrome.storage.local.set({ entries: snapshots }); 118 | 119 | // Close modal and refresh view 120 | const modalInstance = bootstrap.Modal.getInstance(modal); 121 | modalInstance.hide(); 122 | await renderSnapshots(); 123 | }); 124 | } 125 | 126 | async function getAllUniqueTags() { 127 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 128 | return [...new Set(snapshots.flatMap(snapshot => snapshot.tags))] 129 | .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); 130 | } 131 | 132 | function getCurrentModalTags() { 133 | return Array.from( 134 | document.getElementById('currentTagsList') 135 | .querySelectorAll('.badge') 136 | ).map(badge => badge.dataset.tag); 137 | } 138 | 139 | function updateDropdownSelection() { 140 | const dropdown = document.getElementById('tagAutocomplete'); 141 | dropdown.querySelectorAll('.dropdown-item').forEach((item, index) => { 142 | item.classList.toggle('active', index === selectedTagIndex); 143 | }); 144 | } 145 | 146 | async function updateCurrentTagsList() { 147 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 148 | const selectedSnapshotsArray = snapshots.filter(e => selectedSnapshots.has(e.id)); 149 | 150 | // Get tags that exist in ALL selected snapshots 151 | const commonTags = selectedSnapshotsArray.reduce((acc, snapshot) => { 152 | if (!acc) return new Set(snapshot.tags); 153 | return new Set([...acc].filter(tag => snapshot.tags.includes(tag))); 154 | }, null); 155 | 156 | const tagsList = document.getElementById('currentTagsList'); 157 | tagsList.innerHTML = commonTags ? 158 | Array.from(commonTags) 159 | .map(tag => ` 160 | 161 | ${tag} 162 | 163 | `) 164 | .join('') : ''; 165 | 166 | // Add click handlers for tag removal 167 | tagsList.querySelectorAll('.badge').forEach(badge => { 168 | badge.addEventListener('click', () => { 169 | badge.remove(); 170 | }); 171 | }); 172 | } 173 | 174 | async function addTagToModal(tag) { 175 | const currentTags = getCurrentModalTags(); 176 | if (!currentTags.includes(tag)) { 177 | const tagsList = document.getElementById('currentTagsList'); 178 | const newTag = document.createElement('span'); 179 | newTag.className = 'badge bg-secondary me-1 mb-1'; 180 | newTag.setAttribute('role', 'button'); 181 | newTag.dataset.tag = tag; 182 | newTag.innerHTML = `${tag} `; 183 | 184 | newTag.addEventListener('click', () => newTag.remove()); 185 | tagsList.appendChild(newTag); 186 | } 187 | } 188 | 189 | function updateSelectionCount() { 190 | const count = selectedSnapshots.size; 191 | // Update count in main view 192 | document.getElementById('selectedUrlCount').textContent = count; 193 | // Update count in modal 194 | document.getElementById('selectedUrlCountModal').textContent = count; 195 | } 196 | 197 | function updateActionButtonStates() { 198 | const hasSelection = selectedSnapshots.size > 0; 199 | 200 | // Update all action buttons based on selection state 201 | [ 202 | 'downloadCsv', 203 | 'downloadJson', 204 | 'deleteFiltered', 205 | 'syncFiltered', 206 | 'editTags' 207 | ].forEach(buttonId => { 208 | const button = document.getElementById(buttonId); 209 | if (button) { 210 | button.disabled = !hasSelection; 211 | // Add visual feedback for disabled state 212 | button.classList.toggle('opacity-50', !hasSelection); 213 | } 214 | }); 215 | } 216 | 217 | // Add handler for "Select All" checkbox in header if it exists 218 | const selectAllCheckbox = document.getElementById('selectAllUrls'); 219 | if (selectAllCheckbox) { 220 | selectAllCheckbox.addEventListener('click', async () => { 221 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 222 | const filterText = document.getElementById('filterInput').value.toLowerCase(); 223 | 224 | // Get currently filtered snapshots 225 | const filteredSnapshots = filterSnapshots(snapshots, filterText); 226 | 227 | // If all filtered snapshots are selected, deselect all 228 | const allFilteredSelected = filteredSnapshots.every(snapshot => 229 | selectedSnapshots.has(snapshot.id) 230 | ); 231 | 232 | if (allFilteredSelected) { 233 | // Deselect only the filtered snapshots 234 | filteredSnapshots.forEach(snapshot => { 235 | selectedSnapshots.delete(snapshot.id); 236 | }); 237 | } else { 238 | // Select all filtered snapshot 239 | filteredSnapshots.forEach(snapshot => { 240 | selectedSnapshots.add(snapshot.id); 241 | }); 242 | } 243 | 244 | await renderSnapshots(); 245 | }); 246 | } 247 | 248 | // Add handler for "Deselect All" button 249 | const deselectAllButton = document.getElementById('deselectAllUrls'); 250 | if (deselectAllButton) { 251 | deselectAllButton.addEventListener('click', () => { 252 | selectedSnapshots.clear(); 253 | renderSnapshots(); 254 | }); 255 | } 256 | 257 | // Add handler for individual checkbox changes 258 | document.getElementById('snapshotsList').addEventListener('change', (e) => { 259 | if (e.target.classList.contains('snapshot-checkbox')) { 260 | if (e.target.checked) { 261 | selectedSnapshots.add(e.target.value); 262 | } else { 263 | selectedSnapshots.delete(e.target.value); 264 | } 265 | updateSelectionCount(); 266 | updateActionButtonStates(); 267 | } 268 | }); 269 | 270 | // Get initial filter value from URL 271 | function getInitialFilter() { 272 | const params = new URLSearchParams(window.location.search); 273 | return params.get('search') || ''; 274 | } 275 | 276 | // Update URL with current filter 277 | function updateFilterUrl(filterText) { 278 | const newUrl = filterText 279 | ? `${window.location.pathname}?search=${encodeURIComponent(filterText)}` 280 | : window.location.pathname; 281 | window.history.pushState({}, '', newUrl); 282 | } 283 | 284 | async function renderTagsList(filteredSnapshots) { 285 | const tagsList = document.getElementById('tagsList'); 286 | 287 | // Count occurrences of each tag in filtered snapshots only 288 | const tagCounts = filteredSnapshots.reduce((acc, snapshot) => { 289 | snapshot.tags.forEach(tag => { 290 | acc[tag] = (acc[tag] || 0) + 1; 291 | }); 292 | return acc; 293 | }, {}); 294 | 295 | // Sort tags by frequency (descending) then alphabetically 296 | const sortedTags = Object.entries(tagCounts) 297 | .sort(([tagA, countA], [tagB, countB]) => { 298 | if (countB !== countA) return countB - countA; 299 | return tagA.localeCompare(tagB); 300 | }); 301 | 302 | // Get current filter to highlight active tag if any 303 | const currentFilter = document.getElementById('filterInput').value.toLowerCase(); 304 | 305 | // Render tags list with counts 306 | tagsList.innerHTML = sortedTags.map(([tag, count]) => ` 307 | 310 | ${tag} 311 | ${count} 312 | 313 | `).join(''); 314 | 315 | // Add click handlers for tag filtering 316 | tagsList.querySelectorAll('.tag-filter').forEach(tagElement => { 317 | tagElement.addEventListener('click', (e) => { 318 | e.preventDefault(); 319 | const tag = tagElement.dataset.tag; 320 | const filterInput = document.getElementById('filterInput'); 321 | 322 | // Toggle tag filter 323 | if (filterInput.value.toLowerCase() === tag.toLowerCase()) { 324 | filterInput.value = ''; // Clear filter if clicking active tag 325 | } else { 326 | filterInput.value = tag; 327 | } 328 | 329 | renderSnapshots(); 330 | }); 331 | }); 332 | } 333 | 334 | // Modify existing renderSnapshots function 335 | async function renderSnapshots() { 336 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 337 | const archivebox_server_url = await getArchiveBoxServerUrl(); 338 | 339 | const filterText = document.getElementById('filterInput').value.toLowerCase(); 340 | const snapshotsList = document.getElementById('snapshotsList'); 341 | 342 | // Update URL when filter changes 343 | updateFilterUrl(filterText); 344 | 345 | // Filter snapshots based on search text 346 | const filteredSnapshots = filterSnapshots(snapshots, filterText); 347 | 348 | // sort snapshots by timestamp, newest first 349 | filteredSnapshots.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)); 350 | 351 | // Add CSS for URL truncation if not already present 352 | if (!document.getElementById('snapshotsListStyles')) { 353 | const style = document.createElement('style'); 354 | style.id = 'snapshotsListStyles'; 355 | style.textContent = ` 356 | .snapshot-url { 357 | max-width: 800px; 358 | white-space: nowrap; 359 | overflow: hidden; 360 | text-overflow: ellipsis; 361 | display: inline-block; 362 | } 363 | .snapshot-title-line { 364 | display: flex; 365 | align-items: center; 366 | justify-content: space-between; 367 | gap: 8px; 368 | } 369 | .snapshot-title { 370 | font-size: 0.9em; 371 | color: #666; 372 | margin-bottom: 4px; 373 | } 374 | .snapshot-link-to-archivebox { 375 | font-size: 0.7em; 376 | color: #888; 377 | min-width: 330px; 378 | } 379 | .snapshot-timestamp { 380 | font-size: 0.8em; 381 | color: #888; 382 | margin-left: 8px; 383 | } 384 | .snapshot-content { 385 | display: flex; 386 | flex-direction: column; 387 | gap: 2px; 388 | } 389 | .snapshot-url-line { 390 | display: flex; 391 | align-items: center; 392 | gap: 8px; 393 | } 394 | `; 395 | document.head.appendChild(style); 396 | } 397 | 398 | // Render snapshots list 399 | snapshotsList.innerHTML = filteredSnapshots.map(snapshot => ` 400 |
401 | 405 |
406 |
407 |
${snapshot.title || 'Untitled'}
408 | ${(()=>{ 409 | return archivebox_server_url ? 410 | `` 421 | : '' })() 422 | } 423 |
424 |
425 | 428 | ${snapshot.url} 429 | 430 | ${new Date(snapshot.timestamp).toLocaleString()} 431 | 432 |
433 |
434 | ${snapshot.tags.map(tag => 435 | `${tag}` 436 | ).join('')} 437 |
438 |
439 |
440 | `).join(''); 441 | 442 | // Update selection count and action buttons 443 | updateSelectionCount(); 444 | updateActionButtonStates(); 445 | 446 | // Update tags list with filtered snapshots 447 | await renderTagsList(filteredSnapshots); 448 | } 449 | 450 | // Initialize filter input with URL parameter and trigger initial render 451 | const filterInput = document.getElementById('filterInput'); 452 | filterInput.value = getInitialFilter(); 453 | 454 | // Handle filter input changes with debounce 455 | let filterTimeout; 456 | filterInput.addEventListener('input', () => { 457 | clearTimeout(filterTimeout); 458 | filterTimeout = setTimeout(() => { 459 | renderSnapshots(); 460 | }, 300); 461 | }); 462 | 463 | // Handle browser back/forward 464 | window.addEventListener('popstate', () => { 465 | filterInput.value = getInitialFilter(); 466 | renderSnapshots(); 467 | }); 468 | 469 | // Initialize the tag modal when the snapshots tab is initialized 470 | initializeTagModal(); 471 | 472 | // Initial render 473 | renderSnapshots(); 474 | 475 | // Export to CSV 476 | document.getElementById('downloadCsv').addEventListener('click', async () => { 477 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 478 | const selectedItems = snapshots.filter(e => selectedSnapshots.has(e.id)); 479 | 480 | if (!selectedItems.length) { 481 | alert('No snapshots selected'); 482 | return; 483 | } 484 | 485 | downloadCsv(selectedItems); 486 | }); 487 | 488 | // Export to JSON 489 | document.getElementById('downloadJson').addEventListener('click', async () => { 490 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 491 | const selectedItems = snapshots.filter(e => selectedSnapshots.has(e.id)); 492 | 493 | if (!selectedItems.length) { 494 | alert('No snapshots selected'); 495 | return; 496 | } 497 | 498 | downloadJson(selectedItems); 499 | }); 500 | 501 | // Delete snapshots 502 | document.getElementById('deleteFiltered').addEventListener('click', async () => { 503 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 504 | const selectedItems = snapshots.filter(e => selectedSnapshots.has(e.id)); 505 | console.log(`deleting ${selectedItems.length} items from local storage`) 506 | 507 | if (!selectedItems.length) { 508 | alert('No snapshots to delete'); 509 | return; 510 | } 511 | 512 | const message = `Delete ${selectedItems.length} snapshots?` 513 | 514 | if (!confirm(message)) return; 515 | 516 | const idsToDelete = new Set(selectedItems.map(e => e.id)); 517 | const remainingSnapshots = snapshots.filter(e => !idsToDelete.has(e.id)); 518 | await chrome.storage.local.set({ entries: remainingSnapshots }); 519 | 520 | // Refresh the view 521 | await renderSnapshots(); 522 | }); 523 | 524 | // Sync snapshots 525 | document.getElementById('syncFiltered').addEventListener('click', async () => { 526 | const { entries: snapshots = [] } = await chrome.storage.local.get('entries'); 527 | const selectedItems = snapshots.filter(e => selectedSnapshots.has(e.id)); 528 | console.log(`syncing ${selectedItems.length} items to ArchiveBox server`) 529 | 530 | if (!selectedItems.length) { 531 | alert('No selectedItems to sync'); 532 | return; 533 | } 534 | 535 | const syncBtn = document.getElementById('syncFiltered'); 536 | syncBtn.disabled = true; 537 | syncBtn.innerHTML = ' Syncing...'; 538 | 539 | // Process selectedItems one at a time 540 | for (const item of selectedItems) { 541 | const row = document.querySelector(`input[value="${item.id}"]`); 542 | if (!row) continue; 543 | const snapshot = row.parentElement.querySelector('.snapshot-title'); 544 | 545 | // Add status indicator if it doesn't exist 546 | let statusIndicator = snapshot.querySelector('.sync-status'); 547 | if (!statusIndicator) { 548 | statusIndicator = document.createElement('span'); 549 | statusIndicator.className = 'sync-status status-indicator'; 550 | statusIndicator.style.marginLeft = '10px'; 551 | snapshot.appendChild(statusIndicator); 552 | } 553 | 554 | // Update status to "in progress" 555 | statusIndicator.className = 'sync-status status-indicator'; 556 | statusIndicator.style.backgroundColor = '#ffc107'; // yellow 557 | // animate the status indicator pulsing until the request is complete 558 | statusIndicator.style.animation = 'pulse 1s infinite'; 559 | 560 | // Send to ArchiveBox 561 | let success = true, status = 'success'; 562 | try { 563 | await addToArchiveBox([item.url], item.tags); 564 | success = true; 565 | status = 'success'; 566 | } catch (error) { 567 | success = false; 568 | status = error.message; 569 | } 570 | 571 | statusIndicator.className = `sync-status status-indicator status-${success ? 'success' : 'error'}`; 572 | statusIndicator.style.backgroundColor = success ? '#28a745' : '#dc3545'; 573 | statusIndicator.title = status; 574 | statusIndicator.style.animation = 'none'; 575 | 576 | // Wait 0.5s before next request 577 | await new Promise(resolve => setTimeout(resolve, 500)); 578 | } 579 | 580 | // Reset button state 581 | syncBtn.disabled = false; 582 | syncBtn.textContent = '⬆️ Sync to ArchiveBox'; 583 | }); 584 | } 585 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | // Common utility functions 2 | 3 | export class Snapshot { 4 | constructor(url, tags = [], title = '', favIconUrl = null) { 5 | this.id = crypto.randomUUID(); 6 | this.url = url; 7 | this.timestamp = new Date().toISOString(); 8 | this.tags = tags; 9 | this.title = title; 10 | this.favIconUrl = favIconUrl; 11 | } 12 | } 13 | 14 | // Helper to get server URL with fallback to legacy config name 15 | export async function getArchiveBoxServerUrl() { 16 | const { archivebox_server_url } = await chrome.storage.local.get(['archivebox_server_url']); // new ArchiveBox Extension v2.1.3 location 17 | const {config_archiveBoxBaseUrl} = await chrome.storage.sync.get(['config_archiveBoxBaseUrl']); // old ArchiveBox Exporter v1.3.1 location 18 | return archivebox_server_url || config_archiveBoxBaseUrl || ''; 19 | } 20 | 21 | export function filterSnapshots(snapshots, filterText) { 22 | if (!filterText) return snapshots; 23 | 24 | const searchTerms = filterText.toLowerCase().split(' '); 25 | return snapshots.filter(snapshot => { 26 | const searchableText = [ 27 | snapshot.url, 28 | snapshot.title, 29 | snapshot.id, 30 | new Date(snapshot.timestamp).toISOString(), 31 | ...snapshot.tags 32 | ].join(' ').toLowerCase(); 33 | 34 | return searchTerms.every(term => searchableText.includes(term)); 35 | }); 36 | } 37 | 38 | // Common function to format cookies for export used in both personas-tab.js and cookies-tab.js 39 | export function formatCookiesForExport(cookies) { 40 | return Object.entries(cookies).map(([domain, domainCookies]) => { 41 | return `# ${domain}\n${domainCookies.map(cookie => 42 | `${cookie.name}=${cookie.value}; domain=${cookie.domain}; path=${cookie.path}` 43 | ).join('\n')}`; 44 | }).join('\n\n'); 45 | } 46 | 47 | // Status indicator update helper 48 | export function updateStatusIndicator(indicator, textElement, success, message) { 49 | indicator.className = success ? 'status-indicator status-success' : 'status-indicator status-error'; 50 | textElement.textContent = message; 51 | textElement.className = success ? 'text-success' : 'text-danger'; 52 | } 53 | 54 | // Archive URLs on the configured ArchiveBox server instance. 55 | export async function addToArchiveBox(urls, tags = [], depth = 0, update = false, update_all = false) { 56 | const formattedTags = tags.join(','); 57 | console.log(`i Adding urls ${urls} and tags ${formattedTags} to ArchiveBox`); 58 | 59 | const archivebox_server_url = await getArchiveBoxServerUrl(); 60 | const { archivebox_api_key } = await chrome.storage.local.get(['archivebox_api_key']); 61 | 62 | if (!archivebox_server_url) { 63 | throw new Error(`Server not configured`); 64 | } 65 | console.log('i Server url', archivebox_server_url); 66 | 67 | // try ArchiveBox v0.8.0+ API endpoint first 68 | if (archivebox_api_key) { 69 | console.log('i Using v0.8.5 REST API'); 70 | const response = await fetch(`${archivebox_server_url}/api/v1/cli/add`, { 71 | headers: { 72 | 'x-archivebox-api-key': `${archivebox_api_key}` 73 | }, 74 | method: 'post', 75 | credentials: 'include', 76 | body: JSON.stringify({ urls, formattedTags, depth, update, update_all }) 77 | }); 78 | 79 | if (response.ok) { 80 | console.log(`i Successfully added ${urls} to ArchiveBox using v0.8.5 REST API`); 81 | return 82 | } else { 83 | console.warn(`! Failed to add ${urls} to ArchiveBox using v0.8.5 REST API. HTTP ${response.status} ${response.statusText}. Falling back to legacy API.`); 84 | } 85 | } 86 | 87 | // Fall back to pre-v0.8.0 endpoint for backwards compatibility 88 | console.log(`i Using legacy /add POST method`); 89 | 90 | const body = new FormData(); 91 | body.append("url", urls.join("\n")); 92 | body.append("tag", formattedTags); 93 | body.append("parser", "auto") 94 | body.append("depth", depth) 95 | 96 | const response = await fetch(`${archivebox_server_url}/add/`, { 97 | method: "post", 98 | credentials: "include", 99 | body: body 100 | }); 101 | 102 | if (!response.ok) { 103 | throw new Error(`HTTP ${response.status}: ${response.statusText}`); 104 | } 105 | } 106 | 107 | export function downloadCsv(snapshots) { 108 | const headers = ['id', 'timestamp', 'url', 'title', 'tags']; 109 | const csvRows = [ 110 | headers.join(','), 111 | ...snapshots.map(snapshot => { 112 | return [ 113 | snapshot.id, 114 | snapshot.timestamp, 115 | `"${snapshot.url}"`, 116 | `"${snapshot.title || ''}"`, 117 | `"${snapshot.tags.join(';')}"`, 118 | ].join(','); 119 | }) 120 | ]; 121 | 122 | const csvContent = csvRows.join('\n'); 123 | const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); 124 | const url = URL.createObjectURL(blob); 125 | const link = document.createElement('a'); 126 | link.setAttribute('href', url); 127 | link.setAttribute('download', `archivebox-export-${new Date().toISOString().split('T')[0]}.csv`); 128 | document.body.appendChild(link); 129 | link.click(); 130 | document.body.removeChild(link); 131 | } 132 | 133 | export function downloadJson(snapshots) { 134 | const jsonContent = JSON.stringify(snapshots, null, 2); 135 | const blob = new Blob([jsonContent], { type: 'application/json;charset=utf-8;' }); 136 | const url = URL.createObjectURL(blob); 137 | const link = document.createElement('a'); 138 | link.setAttribute('href', url); 139 | link.setAttribute('download', `archivebox-export-${new Date().toISOString().split('T')[0]}.json`); 140 | document.body.appendChild(link); 141 | link.click(); 142 | document.body.removeChild(link); 143 | } 144 | --------------------------------------------------------------------------------