diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index ca0c73c..0000000 --- a/.eslintrc.js +++ /dev/null @@ -1,19 +0,0 @@ -module.exports = { - env: { - browser: true, - es6: true, - }, - extends: ['airbnb-base', 'plugin:prettier/recommended'], - globals: { - Atomics: 'readonly', - SharedArrayBuffer: 'readonly', - }, - parserOptions: { - ecmaVersion: 2018, - }, - ignorePatterns: ['db.js'], - rules: { - 'no-use-before-define': 0, - 'no-underscore-dangle': 0, - }, -}; diff --git a/.gitignore b/.gitignore index bc5a7f6..6e743e6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,6 @@ /.idea/* /.debris/* .DS_Store +coverage/ node_modules +yarn.lock diff --git a/.prettierrc.js b/.prettierrc.js deleted file mode 100644 index 2b588d6..0000000 --- a/.prettierrc.js +++ /dev/null @@ -1,5 +0,0 @@ -module.exports = { - trailingComma: 'es5', - tabWidth: 4, - singleQuote: true, -}; diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a6c8e7c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [1.1.4] - 2025-??-?? + +### Changes + +- Updated to support Chrome Extension Manifest V3. +- Updated all code to modern JavaScript and improved documentation. +- Fixed [issue #3](https://github.com/codedread/spaces/issues/3) by escaping + HTML for all extension content. +- Increased unit test coverage from 0% to ???%. diff --git a/README.md b/README.md index 8bd4230..6f95cf3 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,8 @@ Please note that the webstore version may be behind the latest version here. ### Install as an extension from source -1. Download the **[latest available version](https://github.com/deanoemcke/spaces/archive/v1.1.1.zip)** +1. Download the **[latest available version](https://github.com/codedread/spaces/archive/v1.1.4.zip)** 2. Unarchive to your preferred location (e.g., `Downloads`). 2. In **Google Chrome**, navigate to [chrome://extensions/](chrome://extensions/) and enable Developer mode in the upper right corner. 3. Click on the LOAD UNPACKED button. 4. Browse to the _root directory_ of the unarchived download, and click OPEN. - -> **TODO** — add more sections -> - [ ] Build from github -> - [ ] License (currently unspecified) diff --git a/js/background.js b/js/background.js deleted file mode 100644 index d72364d..0000000 --- a/js/background.js +++ /dev/null @@ -1,1063 +0,0 @@ -/* eslint-disable no-restricted-globals */ -/* eslint-disable no-alert */ -/* global chrome spacesService */ - -/* spaces - * Copyright (C) 2015 Dean Oemcke - */ - -// eslint-disable-next-line no-unused-vars, no-var -var spaces = (() => { - let spacesPopupWindowId = false; - let spacesOpenWindowId = false; - const noop = () => {}; - const debug = false; - - // LISTENERS - - // add listeners for session monitoring - chrome.tabs.onCreated.addListener(tab => { - // this call to checkInternalSpacesWindows actually returns false when it should return true - // due to the event being called before the globalWindowIds get set. oh well, never mind. - if (checkInternalSpacesWindows(tab.windowId, false)) return; - // don't need this listener as the tabUpdated listener also fires when a new tab is created - // spacesService.handleTabCreated(tab); - updateSpacesWindow('tabs.onCreated'); - }); - chrome.tabs.onRemoved.addListener((tabId, removeInfo) => { - if (checkInternalSpacesWindows(removeInfo.windowId, false)) return; - spacesService.handleTabRemoved(tabId, removeInfo, () => { - updateSpacesWindow('tabs.onRemoved'); - }); - }); - chrome.tabs.onMoved.addListener((tabId, moveInfo) => { - if (checkInternalSpacesWindows(moveInfo.windowId, false)) return; - spacesService.handleTabMoved(tabId, moveInfo, () => { - updateSpacesWindow('tabs.onMoved'); - }); - }); - chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { - if (checkInternalSpacesWindows(tab.windowId, false)) return; - - spacesService.handleTabUpdated(tab, changeInfo, () => { - updateSpacesWindow('tabs.onUpdated'); - }); - }); - chrome.windows.onRemoved.addListener(windowId => { - if (checkInternalSpacesWindows(windowId, true)) return; - spacesService.handleWindowRemoved(windowId, true, () => { - updateSpacesWindow('windows.onRemoved'); - }); - - // if this was the last window open and the spaces window is stil open - // then close the spaces window also so that chrome exits fully - // NOTE: this is a workaround for an issue with the chrome 'restore previous session' option - // if the spaces window is the only window open and you try to use it to open a space, - // when that space loads, it also loads all the windows from the window that was last closed - chrome.windows.getAll({}, windows => { - if (windows.length === 1 && spacesOpenWindowId) { - chrome.windows.remove(spacesOpenWindowId); - } - }); - }); - // don't need this listener as the tabUpdated listener also fires when a new window is created - // chrome.windows.onCreated.addListener(function (window) { - - // if (checkInternalSpacesWindows(window.id, false)) return; - // spacesService.handleWindowCreated(window); - // }); - - // add listeners for tab and window focus changes - // when a tab or window is changed, close the move tab popup if it is open - chrome.windows.onFocusChanged.addListener(windowId => { - // Prevent a click in the popup on Ubunto or ChroneOS from closing the - // popup prematurely. - if ( - windowId === chrome.windows.WINDOW_ID_NONE || - windowId === spacesPopupWindowId - ) { - return; - } - - if (!debug && spacesPopupWindowId) { - if (spacesPopupWindowId) { - closePopupWindow(); - } - } - spacesService.handleWindowFocussed(windowId); - }); - - // add listeners for message requests from other extension pages (spaces.html & tab.html) - - chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { - if (debug) { - // eslint-disable-next-line no-console - console.log(`listener fired: ${JSON.stringify(request)}`); - } - - let sessionId; - let windowId; - let tabId; - - // endpoints called by spaces.js - switch (request.action) { - case 'loadSession': - sessionId = _cleanParameter(request.sessionId); - if (sessionId) { - handleLoadSession(sessionId); - sendResponse(true); - } - // close the requesting tab (should be spaces.html) - // if (!debug) closeChromeTab(sender.tab.id); - - return true; - - case 'loadWindow': - windowId = _cleanParameter(request.windowId); - if (windowId) { - handleLoadWindow(windowId); - sendResponse(true); - } - // close the requesting tab (should be spaces.html) - // if (!debug) closeChromeTab(sender.tab.id); - - return true; - - case 'loadTabInSession': - sessionId = _cleanParameter(request.sessionId); - if (sessionId && request.tabUrl) { - handleLoadSession(sessionId, request.tabUrl); - sendResponse(true); - } - // close the requesting tab (should be spaces.html) - // if (!debug) closeChromeTab(sender.tab.id); - - return true; - - case 'loadTabInWindow': - windowId = _cleanParameter(request.windowId); - if (windowId && request.tabUrl) { - handleLoadWindow(windowId, request.tabUrl); - sendResponse(true); - } - // close the requesting tab (should be spaces.html) - // if (!debug) closeChromeTab(sender.tab.id); - - return true; - - case 'saveNewSession': - windowId = _cleanParameter(request.windowId); - if (windowId && request.sessionName) { - handleSaveNewSession( - windowId, - request.sessionName, - sendResponse - ); - } - return true; // allow async response - - case 'importNewSession': - if (request.urlList) { - handleImportNewSession(request.urlList, sendResponse); - } - return true; // allow async response - - case 'restoreFromBackup': - if (request.spaces) { - handleRestoreFromBackup(request.spaces, sendResponse); - } - return true; // allow async response - - case 'deleteSession': - sessionId = _cleanParameter(request.sessionId); - if (sessionId) { - handleDeleteSession(sessionId, false, sendResponse); - } - return true; - - case 'updateSessionName': - sessionId = _cleanParameter(request.sessionId); - if (sessionId && request.sessionName) { - handleUpdateSessionName( - sessionId, - request.sessionName, - sendResponse - ); - } - return true; - - case 'requestSpaceDetail': - windowId = _cleanParameter(request.windowId); - sessionId = _cleanParameter(request.sessionId); - - if (windowId) { - if (checkInternalSpacesWindows(windowId, false)) { - sendResponse(false); - } else { - requestSpaceFromWindowId(windowId, sendResponse); - } - } else if (sessionId) { - requestSpaceFromSessionId(sessionId, sendResponse); - } - return true; - - // end points called by tag.js and switcher.js - // note: some of these endpoints will close the requesting tab - case 'requestAllSpaces': - requestAllSpaces(allSpaces => { - sendResponse(allSpaces); - }); - return true; - - case 'requestHotkeys': - requestHotkeys(sendResponse); - return true; - - case 'requestTabDetail': - tabId = _cleanParameter(request.tabId); - if (tabId) { - requestTabDetail(tabId, tab => { - if (tab) { - sendResponse(tab); - } else { - // close the requesting tab (should be tab.html) - closePopupWindow(); - } - }); - } - return true; - - case 'requestShowSpaces': - windowId = _cleanParameter(request.windowId); - - // show the spaces tab in edit mode for the passed in windowId - if (windowId) { - showSpacesOpenWindow(windowId, request.edit); - } else { - showSpacesOpenWindow(); - } - return false; - - case 'requestShowSwitcher': - showSpacesSwitchWindow(); - return false; - - case 'requestShowMover': - showSpacesMoveWindow(); - return false; - - case 'requestShowKeyboardShortcuts': - createShortcutsWindow(); - return false; - - case 'requestClose': - // close the requesting tab (should be tab.html) - closePopupWindow(); - return false; - - case 'switchToSpace': - windowId = _cleanParameter(request.windowId); - sessionId = _cleanParameter(request.sessionId); - - if (windowId) { - handleLoadWindow(windowId); - } else if (sessionId) { - handleLoadSession(sessionId); - } - - return false; - - case 'addLinkToNewSession': - tabId = _cleanParameter(request.tabId); - if (request.sessionName && request.url) { - handleAddLinkToNewSession( - request.url, - request.sessionName, - result => { - if (result) - updateSpacesWindow('addLinkToNewSession'); - - // close the requesting tab (should be tab.html) - closePopupWindow(); - } - ); - } - return false; - - case 'moveTabToNewSession': - tabId = _cleanParameter(request.tabId); - if (request.sessionName && tabId) { - handleMoveTabToNewSession( - tabId, - request.sessionName, - result => { - if (result) - updateSpacesWindow('moveTabToNewSession'); - - // close the requesting tab (should be tab.html) - closePopupWindow(); - } - ); - } - return false; - - case 'addLinkToSession': - sessionId = _cleanParameter(request.sessionId); - - if (sessionId && request.url) { - handleAddLinkToSession(request.url, sessionId, result => { - if (result) updateSpacesWindow('addLinkToSession'); - - // close the requesting tab (should be tab.html) - closePopupWindow(); - }); - } - return false; - - case 'moveTabToSession': - sessionId = _cleanParameter(request.sessionId); - tabId = _cleanParameter(request.tabId); - - if (sessionId && tabId) { - handleMoveTabToSession(tabId, sessionId, result => { - if (result) updateSpacesWindow('moveTabToSession'); - - // close the requesting tab (should be tab.html) - closePopupWindow(); - }); - } - return false; - - case 'addLinkToWindow': - windowId = _cleanParameter(request.windowId); - - if (windowId && request.url) { - handleAddLinkToWindow(request.url, windowId, result => { - if (result) updateSpacesWindow('addLinkToWindow'); - - // close the requesting tab (should be tab.html) - closePopupWindow(); - }); - } - return false; - - case 'moveTabToWindow': - windowId = _cleanParameter(request.windowId); - tabId = _cleanParameter(request.tabId); - - if (windowId && tabId) { - handleMoveTabToWindow(tabId, windowId, result => { - if (result) updateSpacesWindow('moveTabToWindow'); - - // close the requesting tab (should be tab.html) - closePopupWindow(); - }); - } - return false; - - default: - return false; - } - }); - function _cleanParameter(param) { - if (typeof param === 'number') { - return param; - } - if (param === 'false') { - return false; - } - if (param === 'true') { - return true; - } - return parseInt(param, 10); - } - - // add listeners for keyboard commands - - chrome.commands.onCommand.addListener(command => { - // handle showing the move tab popup (tab.html) - if (command === 'spaces-move') { - showSpacesMoveWindow(); - - // handle showing the switcher tab popup (switcher.html) - } else if (command === 'spaces-switch') { - showSpacesSwitchWindow(); - } - }); - - // add context menu entry - - chrome.contextMenus.create({ - id: 'spaces-add-link', - title: 'Add link to space...', - contexts: ['link'], - }); - chrome.contextMenus.onClicked.addListener(info => { - // handle showing the move tab popup (tab.html) - if (info.menuItemId === 'spaces-add-link') { - showSpacesMoveWindow(info.linkUrl); - } - }); - - // runtime extension install listener - chrome.runtime.onInstalled.addListener(details => { - if (details.reason === 'install') { - // eslint-disable-next-line no-console - console.log('This is a first install!'); - showSpacesOpenWindow(); - } else if (details.reason === 'update') { - const thisVersion = chrome.runtime.getManifest().version; - if (details.previousVersion !== thisVersion) { - // eslint-disable-next-line no-console - console.log( - `Updated from ${details.previousVersion} to ${thisVersion}!` - ); - } - } - }); - - function createShortcutsWindow() { - chrome.tabs.create({ url: 'chrome://extensions/configureCommands' }); - } - - function showSpacesOpenWindow(windowId, editMode) { - let url; - - if (editMode && windowId) { - url = chrome.extension.getURL( - `spaces.html#windowId=${windowId}&editMode=true` - ); - } else { - url = chrome.extension.getURL('spaces.html'); - } - - // if spaces open window already exists then just give it focus (should be up to date) - if (spacesOpenWindowId) { - chrome.windows.get( - spacesOpenWindowId, - { populate: true }, - window => { - chrome.windows.update(spacesOpenWindowId, { - focused: true, - }); - if (window.tabs[0].id) { - chrome.tabs.update(window.tabs[0].id, { url }); - } - } - ); - - // otherwise re-create it - } else { - chrome.windows.create( - { - type: 'popup', - url, - height: screen.height - 100, - width: Math.min(screen.width, 1000), - top: 0, - left: 0, - }, - window => { - spacesOpenWindowId = window.id; - } - ); - } - } - function showSpacesMoveWindow(tabUrl) { - createOrShowSpacesPopupWindow('move', tabUrl); - } - function showSpacesSwitchWindow() { - createOrShowSpacesPopupWindow('switch'); - } - - async function generatePopupParams(action, tabUrl) { - // get currently highlighted tab - const tabs = await new Promise(resolve => { - chrome.tabs.query({ active: true, currentWindow: true }, resolve); - }); - if (tabs.length === 0) return ''; - - const activeTab = tabs[0]; - - // make sure that the active tab is not from an internal spaces window - if (checkInternalSpacesWindows(activeTab.windowId, false)) { - return ''; - } - - const session = spacesService.getSessionByWindowId(activeTab.windowId); - - const name = session ? session.name : ''; - - let params = `action=${action}&windowId=${activeTab.windowId}&sessionName=${name}`; - - if (tabUrl) { - params += `&url=${encodeURIComponent(tabUrl)}`; - } else { - params += `&tabId=${activeTab.id}`; - } - return params; - } - - function createOrShowSpacesPopupWindow(action, tabUrl) { - generatePopupParams(action, tabUrl).then(params => { - const popupUrl = `${chrome.extension.getURL( - 'popup.html' - )}#opener=bg&${params}`; - // if spaces window already exists - if (spacesPopupWindowId) { - chrome.windows.get( - spacesPopupWindowId, - { populate: true }, - window => { - // if window is currently focused then don't update - if (window.focused) { - // else update popupUrl and give it focus - } else { - chrome.windows.update(spacesPopupWindowId, { - focused: true, - }); - if (window.tabs[0].id) { - chrome.tabs.update(window.tabs[0].id, { - url: popupUrl, - }); - } - } - } - ); - - // otherwise create it - } else { - chrome.windows.create( - { - type: 'popup', - url: popupUrl, - focused: true, - height: 450, - width: 310, - top: screen.height - 450, - left: screen.width - 310, - }, - window => { - spacesPopupWindowId = window.id; - } - ); - } - }); - } - - function closePopupWindow() { - if (spacesPopupWindowId) { - chrome.windows.get( - spacesPopupWindowId, - { populate: true }, - spacesWindow => { - if (!spacesWindow) return; - - // remove popup from history - if ( - spacesWindow.tabs.length > 0 && - spacesWindow.tabs[0].url - ) { - chrome.history.deleteUrl({ - url: spacesWindow.tabs[0].url, - }); - } - - // remove popup window - chrome.windows.remove(spacesWindow.id, () => { - if (chrome.runtime.lastError) { - // eslint-disable-next-line no-console - console.log(chrome.runtime.lastError.message); - } - }); - } - ); - } - } - - function updateSpacesWindow(source) { - if (debug) - // eslint-disable-next-line no-console - console.log(`updateSpacesWindow triggered. source: ${source}`); - - requestAllSpaces(allSpaces => { - chrome.runtime.sendMessage({ - action: 'updateSpaces', - spaces: allSpaces, - }); - }); - } - - function checkInternalSpacesWindows(windowId, windowClosed) { - if (windowId === spacesOpenWindowId) { - if (windowClosed) spacesOpenWindowId = false; - return true; - } - if (windowId === spacesPopupWindowId) { - if (windowClosed) spacesPopupWindowId = false; - return true; - } - return false; - } - - function checkSessionOverwrite(session) { - // make sure session being overwritten is not currently open - if (session.windowId) { - alert( - `A session with the name '${session.name}' is currently open an cannot be overwritten` - ); - return false; - - // otherwise prompt to see if user wants to overwrite session - } - return window.confirm(`Replace existing space: ${session.name}?`); - } - - function checkSessionDelete(session) { - return window.confirm( - `Are you sure you want to delete the space: ${session.name}?` - ); - } - - function requestHotkeys(callback) { - chrome.commands.getAll(commands => { - let switchStr; - let moveStr; - let spacesStr; - - commands.forEach(command => { - if (command.name === 'spaces-switch') { - switchStr = command.shortcut; - } else if (command.name === 'spaces-move') { - moveStr = command.shortcut; - } else if (command.name === 'spaces-open') { - spacesStr = command.shortcut; - } - }); - - callback({ - switchCode: switchStr, - moveCode: moveStr, - spacesCode: spacesStr, - }); - }); - } - - function requestTabDetail(tabId, callback) { - chrome.tabs.get(tabId, callback); - } - - function requestCurrentSpace(callback) { - chrome.windows.getCurrent(window => { - requestSpaceFromWindowId(window.id, callback); - }); - } - - // returns a 'space' object which is essentially the same as a session object - // except that includes space.sessionId (session.id) and space.windowId - function requestSpaceFromWindowId(windowId, callback) { - // first check for an existing session matching this windowId - const session = spacesService.getSessionByWindowId(windowId); - - if (session) { - callback({ - sessionId: session.id, - windowId: session.windowId, - name: session.name, - tabs: session.tabs, - history: session.history, - }); - - // otherwise build a space object out of the actual window - } else { - chrome.windows.get(windowId, { populate: true }, window => { - // if failed to load requested window - if (chrome.runtime.lastError) { - callback(false); - } else { - callback({ - sessionId: false, - windowId: window.id, - name: false, - tabs: window.tabs, - history: false, - }); - } - }); - } - } - - function requestSpaceFromSessionId(sessionId, callback) { - const session = spacesService.getSessionBySessionId(sessionId); - - callback({ - sessionId: session.id, - windowId: session.windowId, - name: session.name, - tabs: session.tabs, - history: session.history, - }); - } - - function requestAllSpaces(callback) { - const sessions = spacesService.getAllSessions(); - const allSpaces = sessions - .map(session => { - return { sessionId: session.id, ...session }; - }) - .filter(session => { - return session && session.tabs && session.tabs.length > 0; - }); - - // sort results - allSpaces.sort(spaceDateCompare); - - callback(allSpaces); - } - - function spaceDateCompare(a, b) { - // order open sessions first - if (a.windowId && !b.windowId) { - return -1; - } - if (!a.windowId && b.windowId) { - return 1; - } - // then order by last access date - if (a.lastAccess > b.lastAccess) { - return -1; - } - if (a.lastAccess < b.lastAccess) { - return 1; - } - return 0; - } - - function handleLoadSession(sessionId, tabUrl) { - const session = spacesService.getSessionBySessionId(sessionId); - - // if space is already open, then give it focus - if (session.windowId) { - handleLoadWindow(session.windowId, tabUrl); - - // else load space in new window - } else { - const urls = session.tabs.map(curTab => { - return curTab.url; - }); - chrome.windows.create( - { - url: urls, - height: screen.height - 100, - width: screen.width - 100, - top: 0, - left: 0, - }, - newWindow => { - // force match this new window to the session - spacesService.matchSessionToWindow(session, newWindow); - - // after window has loaded try to pin any previously pinned tabs - session.tabs.forEach(curSessionTab => { - if (curSessionTab.pinned) { - let pinnedTabId = false; - newWindow.tabs.some(curNewTab => { - if ( - curNewTab.url === curSessionTab.url || - curNewTab.pendingUrl === curSessionTab.url - ) { - pinnedTabId = curNewTab.id; - return true; - } - return false; - }); - if (pinnedTabId) { - chrome.tabs.update(pinnedTabId, { - pinned: true, - }); - } - } - }); - - // if tabUrl is defined, then focus this tab - if (tabUrl) { - focusOrLoadTabInWindow(newWindow, tabUrl); - } - - /* session.tabs.forEach(function (curTab) { - chrome.tabs.create({windowId: newWindow.id, url: curTab.url, pinned: curTab.pinned, active: false}); - }); - - chrome.tabs.query({windowId: newWindow.id, index: 0}, function (tabs) { - chrome.tabs.remove(tabs[0].id); - }); */ - } - ); - } - } - function handleLoadWindow(windowId, tabUrl) { - // assume window is already open, give it focus - if (windowId) { - focusWindow(windowId); - } - - // if tabUrl is defined, then focus this tab - if (tabUrl) { - chrome.windows.get(windowId, { populate: true }, window => { - focusOrLoadTabInWindow(window, tabUrl); - }); - } - } - - function focusWindow(windowId) { - chrome.windows.update(windowId, { focused: true }); - } - - function focusOrLoadTabInWindow(window, tabUrl) { - const match = window.tabs.some(tab => { - if (tab.url === tabUrl) { - chrome.tabs.update(tab.id, { active: true }); - return true; - } - return false; - }); - if (!match) { - chrome.tabs.create({ url: tabUrl }); - } - } - - function handleSaveNewSession(windowId, sessionName, callback) { - chrome.windows.get(windowId, { populate: true }, curWindow => { - const existingSession = spacesService.getSessionByName(sessionName); - - // if session with same name already exist, then prompt to override the existing session - if (existingSession) { - if (!checkSessionOverwrite(existingSession)) { - callback(false); - return; - - // if we choose to overwrite, delete the existing session - } - handleDeleteSession(existingSession.id, true, noop); - } - spacesService.saveNewSession( - sessionName, - curWindow.tabs, - curWindow.id, - callback - ); - }); - } - - function handleRestoreFromBackup(_spaces, callback) { - let existingSession; - let performSave; - - const promises = []; - for (let i = 0; i < _spaces.length; i += 1) { - const space = _spaces[i]; - existingSession = space.name - ? spacesService.getSessionByName(space.name) - : false; - performSave = true; - - // if session with same name already exist, then prompt to override the existing session - if (existingSession) { - if (!checkSessionOverwrite(existingSession)) { - performSave = false; - - // if we choose to overwrite, delete the existing session - } else { - handleDeleteSession(existingSession.id, true, noop); - } - } - - if (performSave) { - promises.push( - new Promise(resolve => { - spacesService.saveNewSession( - space.name, - space.tabs, - false, - resolve - ); - }) - ); - } - } - Promise.all(promises).then(callback); - } - - function handleImportNewSession(urlList, callback) { - let tempName = 'Imported space: '; - let count = 1; - - while (spacesService.getSessionByName(tempName + count)) { - count += 1; - } - - tempName += count; - - const tabList = urlList.map(text => { - return { url: text }; - }); - - // save session to database - spacesService.saveNewSession(tempName, tabList, false, callback); - } - - function handleUpdateSessionName(sessionId, sessionName, callback) { - // check to make sure session name doesn't already exist - const existingSession = spacesService.getSessionByName(sessionName); - - // if session with same name already exist, then prompt to override the existing session - if (existingSession && existingSession.id !== sessionId) { - if (!checkSessionOverwrite(existingSession)) { - callback(false); - return; - - // if we choose to override, then delete the existing session - } - handleDeleteSession(existingSession.id, true, noop); - } - spacesService.updateSessionName(sessionId, sessionName, callback); - } - - function handleDeleteSession(sessionId, force, callback) { - const session = spacesService.getSessionBySessionId(sessionId); - if (!force && !checkSessionDelete(session)) { - callback(false); - } else { - spacesService.deleteSession(sessionId, callback); - } - } - - function handleAddLinkToNewSession(url, sessionName, callback) { - const session = spacesService.getSessionByName(sessionName); - const newTabs = [{ url }]; - - // if we found a session matching this name then return as an error as we are - // supposed to be creating a new session with this name - if (session) { - callback(false); - - // else create a new session with this name containing this url - } else { - spacesService.saveNewSession(sessionName, newTabs, false, callback); - } - } - - function handleMoveTabToNewSession(tabId, sessionName, callback) { - requestTabDetail(tabId, tab => { - const session = spacesService.getSessionByName(sessionName); - - // if we found a session matching this name then return as an error as we are - // supposed to be creating a new session with this name - if (session) { - callback(false); - - // else create a new session with this name containing this tab - } else { - // remove tab from current window (should generate window events) - chrome.tabs.remove(tab.id); - - // save session to database - spacesService.saveNewSession( - sessionName, - [tab], - false, - callback - ); - } - }); - } - - function handleAddLinkToSession(url, sessionId, callback) { - const session = spacesService.getSessionBySessionId(sessionId); - const newTabs = [{ url }]; - - // if we have not found a session matching this name then return as an error as we are - // supposed to be adding the tab to an existing session - if (!session) { - callback(false); - return; - } - // if session is currently open then add link directly - if (session.windowId) { - handleAddLinkToWindow(url, session.windowId, callback); - - // else add tab to saved session in database - } else { - // update session in db - session.tabs = session.tabs.concat(newTabs); - spacesService.updateSessionTabs(session.id, session.tabs, callback); - } - } - - function handleAddLinkToWindow(url, windowId, callback) { - chrome.tabs.create({ windowId, url, active: false }); - - // NOTE: this move does not seem to trigger any tab event listeners - // so we need to update sessions manually - spacesService.queueWindowEvent(windowId); - - callback(true); - } - - function handleMoveTabToSession(tabId, sessionId, callback) { - requestTabDetail(tabId, tab => { - const session = spacesService.getSessionBySessionId(sessionId); - const newTabs = [tab]; - - // if we have not found a session matching this name then return as an error as we are - // supposed to be adding the tab to an existing session - if (!session) { - callback(false); - } else { - // if session is currently open then move it directly - if (session.windowId) { - moveTabToWindow(tab, session.windowId, callback); - return; - } - - // else add tab to saved session in database - // remove tab from current window - chrome.tabs.remove(tab.id); - - // update session in db - session.tabs = session.tabs.concat(newTabs); - spacesService.updateSessionTabs( - session.id, - session.tabs, - callback - ); - } - }); - } - - function handleMoveTabToWindow(tabId, windowId, callback) { - requestTabDetail(tabId, tab => { - moveTabToWindow(tab, windowId, callback); - }); - } - function moveTabToWindow(tab, windowId, callback) { - chrome.tabs.move(tab.id, { windowId, index: -1 }); - - // NOTE: this move does not seem to trigger any tab event listeners - // so we need to update sessions manually - spacesService.queueWindowEvent(tab.windowId); - spacesService.queueWindowEvent(windowId); - - callback(true); - } - - return { - requestSpaceFromWindowId, - requestCurrentSpace, - requestHotkeys, - generatePopupParams, - }; -})(); - -spacesService.initialiseSpaces(); -spacesService.initialiseTabHistory(); diff --git a/js/background/background.js b/js/background/background.js new file mode 100644 index 0000000..97f468b --- /dev/null +++ b/js/background/background.js @@ -0,0 +1,1259 @@ +/* eslint-disable no-restricted-globals */ +/* eslint-disable no-alert */ +/* global chrome spacesService */ + +/* spaces + * Copyright (C) 2015 Dean Oemcke + */ + +import { dbService } from './dbService.js'; +import { spacesService } from './spacesService.js'; +import * as common from '../common.js'; +/** @typedef {common.Space} Space */ + +// eslint-disable-next-line no-unused-vars, no-var +let spacesPopupWindowId = false; +let spacesOpenWindowId = false; +const debug = false; + +async function rediscoverWindowIds() { + spacesOpenWindowId = await rediscoverWindowByUrl('spacesOpenWindowId', 'spaces.html'); + spacesPopupWindowId = await rediscoverWindowByUrl('spacesPopupWindowId', 'popup.html'); +} + +async function rediscoverWindowByUrl(storageKey, htmlFilename) { + // Try to restore from storage first + const stored = await chrome.storage.local.get(storageKey); + if (stored[storageKey]) { + // Verify the window still exists + try { + const window = await chrome.windows.get(stored[storageKey]); + if (window) { + return stored[storageKey]; + } + } catch (error) { + // Window doesn't exist, remove from storage + await chrome.storage.local.remove(storageKey); + } + } + + // If not in storage or window doesn't exist, search for window by URL + const targetUrl = chrome.runtime.getURL(htmlFilename); + const allWindows = await chrome.windows.getAll({populate: true}); + + for (const window of allWindows) { + for (const tab of window.tabs) { + if (tab.url && tab.url.startsWith(targetUrl)) { + await chrome.storage.local.set({[storageKey]: window.id}); + return window.id; + } + } + } + + return false; +} + +export function initializeServiceWorker() { + console.log(`Initializing service worker...`); + + chrome.runtime.onInstalled.addListener(details => { + console.log(`Extension installed: ${JSON.stringify(details)}`); + + if (details.reason === 'install') { + // eslint-disable-next-line no-console + console.log('This is a first install!'); + showSpacesOpenWindow(); + } else if (details.reason === 'update') { + const thisVersion = chrome.runtime.getManifest().version; + if (details.previousVersion !== thisVersion) { + // eslint-disable-next-line no-console + console.log( + `Updated from ${details.previousVersion} to ${thisVersion}!` + ); + } + } + + chrome.contextMenus.create({ + id: 'spaces-add-link', + title: 'Add link to space...', + contexts: ['link'], + }); + }); + + // Handle Chrome startup - this is when window IDs get reassigned! + chrome.runtime.onStartup.addListener(async () => { + await spacesService.clearWindowIdAssociations(); + await spacesService.initialiseSpaces(); + await rediscoverWindowIds(); + }); + + // LISTENERS + + // add listeners for session monitoring + chrome.tabs.onCreated.addListener(async (tab) => { + // this call to checkInternalSpacesWindows actually returns false when it should return true + // due to the event being called before the globalWindowIds get set. oh well, never mind. + if (checkInternalSpacesWindows(tab.windowId, false)) return; + // don't need this listener as the tabUpdated listener also fires when a new tab is created + // spacesService.handleTabCreated(tab); + updateSpacesWindow('tabs.onCreated'); + }); + + chrome.tabs.onRemoved.addListener(async (tabId, removeInfo) => { + if (checkInternalSpacesWindows(removeInfo.windowId, false)) return; + spacesService.handleTabRemoved(tabId, removeInfo, () => { + updateSpacesWindow('tabs.onRemoved'); + }); + }); + + chrome.tabs.onMoved.addListener(async (tabId, moveInfo) => { + if (checkInternalSpacesWindows(moveInfo.windowId, false)) return; + spacesService.handleTabMoved(tabId, moveInfo, () => { + updateSpacesWindow('tabs.onMoved'); + }); + }); + + chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (checkInternalSpacesWindows(tab.windowId, false)) return; + + spacesService.handleTabUpdated(tab, changeInfo, () => { + updateSpacesWindow('tabs.onUpdated'); + }); + }); + + chrome.windows.onRemoved.addListener(async (windowId) => { + if (checkInternalSpacesWindows(windowId, true)) return; + const wasProcessed = await spacesService.handleWindowRemoved(windowId, true); + if (wasProcessed) { + updateSpacesWindow('windows.onRemoved'); + } + + // if this was the last window open and the spaces window is stil open + // then close the spaces window also so that chrome exits fully + // NOTE: this is a workaround for an issue with the chrome 'restore previous session' option + // if the spaces window is the only window open and you try to use it to open a space, + // when that space loads, it also loads all the windows from the window that was last closed + const windows = await chrome.windows.getAll({}); + if (windows.length === 1 && spacesOpenWindowId) { + await chrome.windows.remove(spacesOpenWindowId); + spacesOpenWindowId = false; + await chrome.storage.local.remove('spacesOpenWindowId'); + } + }); + + // Add listener for window creation to ensure new windows are detected + chrome.windows.onCreated.addListener(function (window) { + if (checkInternalSpacesWindows(window.id, false)) return; + setTimeout(() => updateSpacesWindow('windows.onCreated'), 100); + }); + + // add listeners for tab and window focus changes + // when a tab or window is changed, close the move tab popup if it is open + chrome.windows.onFocusChanged.addListener(async (windowId) => { + // Prevent a click in the popup on Ubunto or ChroneOS from closing the + // popup prematurely. + if ( + windowId === chrome.windows.WINDOW_ID_NONE || + windowId === spacesPopupWindowId + ) { + return; + } + + if (!debug && spacesPopupWindowId) { + if (spacesPopupWindowId) { + await closePopupWindow(); + } + } + + spacesService.handleWindowFocussed(windowId); + }); + + // add listeners for message requests from other extension pages (spaces.html & tab.html) + + chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { + if (debug) { + // eslint-disable-next-line no-console + console.log(`listener fired: ${JSON.stringify(request)}`); + } + + // Handle async processing + (async () => { + try { + // Ensure spacesService is initialized before processing any message + await spacesService.ensureInitialized(); + + const response = await processMessage(request, sender); + if (response !== undefined) { + sendResponse(response); + } + } catch (error) { + console.error('Error processing message:', error); + sendResponse(false); + } + })(); + + // We must return true synchronously to keep the message port open + // for our async sendResponse() calls + return true; + }); + + chrome.commands.onCommand.addListener(command => { + // handle showing the move tab popup (tab.html) + if (command === 'spaces-move') { + showSpacesMoveWindow(); + + // handle showing the switcher tab popup (switcher.html) + } else if (command === 'spaces-switch') { + showSpacesSwitchWindow(); + } + }); + + chrome.contextMenus.onClicked.addListener(info => { + // handle showing the move tab popup (tab.html) + if (info.menuItemId === 'spaces-add-link') { + showSpacesMoveWindow(info.linkUrl); + } + }); + + console.log(`Initializing spacesService...`); + spacesService.initialiseSpaces(); +} + +/** + * Processes incoming messages from extension pages and returns appropriate responses. + * + * This function handles all message types sent from popup.html, spaces.html, and other + * extension pages. It performs the requested action and returns data that will be + * sent back to the requesting page via sendResponse(). + * + * @param {Object} request The message request object containing action and parameters. + * It must have an action string property. + * @param {chrome.runtime.MessageSender} sender + * @returns {Promise} Promise that resolves to: + * - Response data (any type) that will be sent to the caller + * - undefined when no response should be sent to the caller + */ +async function processMessage(request, sender) { + let sessionId; + let windowId; + let tabId; + + // endpoints called by spaces.js + switch (request.action) { + case 'requestSessionPresence': + return requestSessionPresence(request.sessionName); + + case 'requestSpaceFromWindowId': + windowId = cleanParameter(request.windowId); + if (windowId) { + return requestSpaceFromWindowId(windowId); + } + return undefined; + + case 'requestCurrentSpace': + return requestCurrentSpace(); + + case 'generatePopupParams': + // TODO: Investigate if || request.action should be removed. + return generatePopupParams(request.popupAction || request.action, request.tabUrl); + + case 'loadSession': + sessionId = cleanParameter(request.sessionId); + if (sessionId) { + await handleLoadSession(sessionId); + return true; + } + // close the requesting tab (should be spaces.html) + // if (!debug) closeChromeTab(sender.tab.id); + return undefined; + + case 'loadWindow': + windowId = cleanParameter(request.windowId); + if (windowId) { + await handleLoadWindow(windowId); + return true; + } + // close the requesting tab (should be spaces.html) + // if (!debug) closeChromeTab(sender.tab.id); + return undefined; + + case 'loadTabInSession': + sessionId = cleanParameter(request.sessionId); + if (sessionId && request.tabUrl) { + await handleLoadSession(sessionId, request.tabUrl); + return true; + } + // close the requesting tab (should be spaces.html) + // if (!debug) closeChromeTab(sender.tab.id); + return undefined; + + case 'loadTabInWindow': + windowId = cleanParameter(request.windowId); + if (windowId && request.tabUrl) { + await handleLoadWindow(windowId, request.tabUrl); + return true; + } + // close the requesting tab (should be spaces.html) + // if (!debug) closeChromeTab(sender.tab.id); + return undefined; + + case 'saveNewSession': + windowId = cleanParameter(request.windowId); + if (windowId && request.sessionName) { + return handleSaveNewSession( + windowId, + request.sessionName, + !!request.deleteOld + ); + } + return undefined; + + case 'importNewSession': + if (request.urlList) { + return handleImportNewSession(request.urlList); + } + return undefined; + + case 'restoreFromBackup': + if (request.space) { + return handleRestoreFromBackup(request.space, !!request.deleteOld); + } + return undefined; + + case 'deleteSession': + sessionId = cleanParameter(request.sessionId); + if (sessionId) { + return handleDeleteSession(sessionId); + } + return undefined; + + case 'updateSessionName': + sessionId = cleanParameter(request.sessionId); + if (sessionId && request.sessionName) { + return handleUpdateSessionName( + sessionId, + request.sessionName, + !!request.deleteOld + ); + } + return undefined; + + case 'requestSpaceDetail': + windowId = cleanParameter(request.windowId); + sessionId = cleanParameter(request.sessionId); + + if (windowId) { + if (checkInternalSpacesWindows(windowId, false)) { + return false; + } else { + return requestSpaceFromWindowId(windowId); + } + } else if (sessionId) { + return requestSpaceFromSessionId(sessionId); + } + return undefined; + + // end points called by tag.js and switcher.js + // note: some of these endpoints will close the requesting tab + case 'requestAllSpaces': + return requestAllSpaces(); + + case 'requestTabDetail': + tabId = cleanParameter(request.tabId); + if (tabId) { + const tab = await requestTabDetail(tabId); + if (tab) { + return tab; + } else { + // close the requesting tab (should be tab.html) + await closePopupWindow(); + } + } + return undefined; + + case 'requestShowSpaces': + windowId = cleanParameter(request.windowId); + + // show the spaces tab in edit mode for the passed in windowId + if (windowId) { + await showSpacesOpenWindow(windowId, request.edit); + } else { + await showSpacesOpenWindow(); + } + return undefined; + + case 'requestShowSwitcher': + showSpacesSwitchWindow(); + return undefined; + + case 'requestShowMover': + showSpacesMoveWindow(); + return undefined; + + case 'requestShowKeyboardShortcuts': + createShortcutsWindow(); + return undefined; + + case 'requestClose': + // close the requesting tab (should be tab.html) + await closePopupWindow(); + return undefined; + + case 'switchToSpace': + windowId = cleanParameter(request.windowId); + sessionId = cleanParameter(request.sessionId); + + if (windowId) { + await handleLoadWindow(windowId); + } else if (sessionId) { + await handleLoadSession(sessionId); + } + return true; + + case 'addLinkToNewSession': + tabId = cleanParameter(request.tabId); + if (request.sessionName && request.url) { + const result = await handleAddLinkToNewSession( + request.url, + request.sessionName + ); + if (result) updateSpacesWindow('addLinkToNewSession'); + + // close the requesting tab (should be tab.html) + closePopupWindow(); + } + return undefined; + + case 'moveTabToNewSession': + tabId = cleanParameter(request.tabId); + if (request.sessionName && tabId) { + const result = await handleMoveTabToNewSession( + tabId, + request.sessionName + ); + if (result) updateSpacesWindow('moveTabToNewSession'); + + // close the requesting tab (should be tab.html) + closePopupWindow(); + } + return undefined; + + case 'addLinkToSession': + sessionId = cleanParameter(request.sessionId); + + if (sessionId && request.url) { + const result = await handleAddLinkToSession(request.url, sessionId); + if (result) updateSpacesWindow('addLinkToSession'); + + // close the requesting tab (should be tab.html) + closePopupWindow(); + } + return undefined; + + case 'moveTabToSession': + sessionId = cleanParameter(request.sessionId); + tabId = cleanParameter(request.tabId); + + if (sessionId && tabId) { + const result = await handleMoveTabToSession(tabId, sessionId); + if (result) updateSpacesWindow('moveTabToSession'); + + // close the requesting tab (should be tab.html) + closePopupWindow(); + } + return undefined; + + case 'addLinkToWindow': + windowId = cleanParameter(request.windowId); + + if (windowId && request.url) { + handleAddLinkToWindow(request.url, windowId); + updateSpacesWindow('addLinkToWindow'); + + // close the requesting tab (should be tab.html) + closePopupWindow(); + } + return undefined; + + case 'moveTabToWindow': + windowId = cleanParameter(request.windowId); + tabId = cleanParameter(request.tabId); + + if (windowId && tabId) { + const result = await handleMoveTabToWindow(tabId, windowId); + if (result) { + updateSpacesWindow('moveTabToWindow'); + } + + // close the requesting tab (should be tab.html) + closePopupWindow(); + } + return undefined; + + default: + return undefined; + } +} + +/** + * Ensures the parameter is a number. + * @param {string|number} param - The parameter to clean. + * @returns {number} - The cleaned parameter. + */ +function cleanParameter(param) { + if (typeof param === 'number') { + return param; + } + if (param === 'false') { + return false; + } + if (param === 'true') { + return true; + } + return parseInt(param, 10); +} + +function createShortcutsWindow() { + chrome.tabs.create({ url: 'chrome://extensions/configureCommands' }); +} + +async function showSpacesOpenWindow(windowId, editMode) { + let url; + + if (editMode && windowId) { + url = chrome.runtime.getURL( + `spaces.html#windowId=${windowId}&editMode=true` + ); + } else { + url = chrome.runtime.getURL('spaces.html'); + } + + // if spaces open window already exists then just give it focus (should be up to date) + if (spacesOpenWindowId) { + const window = await chrome.windows.get(spacesOpenWindowId, { populate: true }); + await chrome.windows.update(spacesOpenWindowId, { + focused: true, + }); + if (window.tabs[0].id) { + await chrome.tabs.update(window.tabs[0].id, { url }); + } + + // otherwise re-create it + } else { + // TODO(codedread): Handle multiple displays and errors. + const displays = await chrome.system.display.getInfo(); + let screen = displays[0].bounds; + const window = await chrome.windows.create( + { + type: 'popup', + url, + height: screen.height - 100, + width: Math.min(screen.width, 1000), + top: 0, + left: 0, + }); + spacesOpenWindowId = window.id; + await chrome.storage.local.set({spacesOpenWindowId: window.id}); + } +} + +function showSpacesMoveWindow(tabUrl) { + createOrShowSpacesPopupWindow('move', tabUrl); +} + +function showSpacesSwitchWindow() { + createOrShowSpacesPopupWindow('switch'); +} + +async function generatePopupParams(action, tabUrl) { + // get currently highlighted tab + const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tabs.length === 0) return ''; + + const activeTab = tabs[0]; + + // make sure that the active tab is not from an internal spaces window + if (checkInternalSpacesWindows(activeTab.windowId, false)) { + return ''; + } + + const session = await dbService.fetchSessionByWindowId(activeTab.windowId); + + const name = session ? session.name : ''; + + let params = `action=${action}&windowId=${activeTab.windowId}&sessionName=${name}`; + + if (tabUrl) { + params += `&url=${encodeURIComponent(tabUrl)}`; + } else { + params += `&tabId=${activeTab.id}`; + } + return params; +} + +async function createOrShowSpacesPopupWindow(action, tabUrl) { + const params = await generatePopupParams(action, tabUrl); + const popupUrl = `${chrome.runtime.getURL( + 'popup.html' + )}#opener=bg&${params}`; + // if spaces window already exists + if (spacesPopupWindowId) { + const window = await chrome.windows.get( + spacesPopupWindowId, + { populate: true } + ); + // if window is currently focused then don't update + if (window.focused) { + // else update popupUrl and give it focus + } else { + await chrome.windows.update(spacesPopupWindowId, { + focused: true, + }); + if (window.tabs[0].id) { + await chrome.tabs.update(window.tabs[0].id, { + url: popupUrl, + }); + } + } + + // otherwise create it + } else { + // TODO(codedread): Handle multiple displays and errors. + const displays = await chrome.system.display.getInfo(); + let screen = displays[0].bounds; + + const window = await chrome.windows.create( + { + type: 'popup', + url: popupUrl, + focused: true, + height: 450, + width: 310, + top: screen.height - 450, + left: screen.width - 310, + }); + spacesPopupWindowId = window.id; + await chrome.storage.local.set({spacesPopupWindowId: window.id}); + } +} + +async function closePopupWindow() { + if (spacesPopupWindowId) { + try { + const spacesWindow = await chrome.windows.get( + spacesPopupWindowId, + { populate: true } + ); + if (!spacesWindow) return; + + // remove popup from history + if ( + spacesWindow.tabs.length > 0 && + spacesWindow.tabs[0].url + ) { + await chrome.history.deleteUrl({ + url: spacesWindow.tabs[0].url, + }); + } + + // remove popup window + await chrome.windows.remove(spacesWindow.id); + } catch (e) { + // eslint-disable-next-line no-console + console.log(e.message); + } + } +} + +async function updateSpacesWindow(source) { + if (debug) { + // eslint-disable-next-line no-console + console.log(`updateSpacesWindow: triggered. source: ${source}`); + } + + // If we don't have a cached spacesOpenWindowId, try to find the spaces window + if (!spacesOpenWindowId) { + await rediscoverWindowIds(); + } + + if (spacesOpenWindowId) { + const spacesOpenWindow = await chrome.windows.get(spacesOpenWindowId); + if (chrome.runtime.lastError || !spacesOpenWindow) { + // eslint-disable-next-line no-console + console.log(`updateSpacesWindow: Error getting spacesOpenWindow: ${chrome.runtime.lastError}`); + spacesOpenWindowId = false; + await chrome.storage.local.remove('spacesOpenWindowId'); + return; + } + + try { + const allSpaces = await requestAllSpaces(); + chrome.runtime.sendMessage({ + action: 'updateSpaces', + spaces: allSpaces, + }); + } catch (err) { + // eslint-disable-next-line no-console + console.error(`updateSpacesWindow: Error updating spaces window: ${err}`); + } + } +} + +function checkInternalSpacesWindows(windowId, windowClosed) { + if (windowId === spacesOpenWindowId) { + if (windowClosed) { + spacesOpenWindowId = false; + chrome.storage.local.remove('spacesOpenWindowId'); + } + return true; + } + if (windowId === spacesPopupWindowId) { + if (windowClosed) { + spacesPopupWindowId = false; + chrome.storage.local.remove('spacesPopupWindowId'); + } + return true; + } + return false; +} + +/** + * @param {string} sessionName + * @returns {SessionPresence} + */ +async function requestSessionPresence(sessionName) { + const session = await dbService.fetchSessionByName(sessionName); + return { exists: !!session, isOpen: !!session && !!session.windowId }; +} + +/** + * @param {number} tabId - The ID of the tab to retrieve details for + * @returns {Promise} A Promise that resolves to the tab object or null. + */ +async function requestTabDetail(tabId) { + try { + return await chrome.tabs.get(tabId); + } catch (error) { + return null; + } +} + +/** + * Requests the current space based on the current window. + * @returns {Promise} + */ +async function requestCurrentSpace() { + const window = await chrome.windows.getCurrent(); + return requestSpaceFromWindowId(window.id); +} + +/** + * @param {number} windowId + * @returns {Promise} + */ +async function requestSpaceFromWindowId(windowId) { + // first check for an existing session matching this windowId + const session = await dbService.fetchSessionByWindowId(windowId); + + if (session) { + /** @type {Space} */ + const space = { + sessionId: session.id, + windowId: session.windowId, + name: session.name, + tabs: session.tabs, + history: session.history, + }; + return space; + + // otherwise build a space object out of the actual window + } else { + try { + const window = await chrome.windows.get(windowId, { populate: true }); + /** @type {Space} */ + const space = { + sessionId: false, + windowId: window.id, + name: false, + tabs: window.tabs, + history: false, + }; + return space; + } catch (e) { + return false; + } + } +} + +/** + * Requests space details for a specific session ID. + * + * @param {number} sessionId + * @returns {Promise} Promise that resolves to: + * - Space object if session exists + * - null if session not found + */ +async function requestSpaceFromSessionId(sessionId) { + const session = await dbService.fetchSessionById(sessionId); + + if (!session) { + return null; + } + + return { + sessionId: session.id, + windowId: session.windowId, + name: session.name, + tabs: session.tabs, + history: session.history, + }; +} + +/** + * Requests all spaces (sessions) from the database. + * + * @returns {Promise} Promise that resolves to an array of Space objects + */ +async function requestAllSpaces() { + // Get all sessions from spacesService (includes both saved and temporary open window sessions) + const allSessions = await spacesService.getAllSessions(); + /** @type {Space[]} */ + const allSpaces = allSessions + .map(session => { + return { sessionId: session.id, ...session }; + }) + .filter(session => { + return session && session.tabs && session.tabs.length > 0; + }); + + // sort results + allSpaces.sort(spaceDateCompare); + + return allSpaces; +} + +function spaceDateCompare(a, b) { + // order open sessions first + if (a.windowId && !b.windowId) { + return -1; + } + if (!a.windowId && b.windowId) { + return 1; + } + // then order by last access date + if (a.lastAccess > b.lastAccess) { + return -1; + } + if (a.lastAccess < b.lastAccess) { + return 1; + } + return 0; +} + +async function handleLoadSession(sessionId, tabUrl) { + const session = await dbService.fetchSessionById(sessionId); + + // if space is already open, then give it focus + if (session.windowId) { + await handleLoadWindow(session.windowId, tabUrl); + + // else load space in new window + } else { + const urls = session.tabs.map(curTab => { + return curTab.url; + }); + + // TODO(codedread): Handle multiple displays and errors. + const displays = await chrome.system.display.getInfo(); + let screen = displays[0].bounds; + + const newWindow = await chrome.windows.create( + { + url: urls, + height: screen.height - 100, + width: screen.width - 100, + top: 0, + left: 0, + }); + + // force match this new window to the session + await spacesService.matchSessionToWindow(session, newWindow); + + // after window has loaded try to pin any previously pinned tabs + for (const curSessionTab of session.tabs) { + if (curSessionTab.pinned) { + let pinnedTabId = false; + newWindow.tabs.some(curNewTab => { + if ( + curNewTab.url === curSessionTab.url || + curNewTab.pendingUrl === curSessionTab.url + ) { + pinnedTabId = curNewTab.id; + return true; + } + return false; + }); + if (pinnedTabId) { + await chrome.tabs.update(pinnedTabId, { + pinned: true, + }); + } + } + } + + // if tabUrl is defined, then focus this tab + if (tabUrl) { + await focusOrLoadTabInWindow(newWindow, tabUrl); + } + + /* session.tabs.forEach(function (curTab) { + chrome.tabs.create({windowId: newWindow.id, url: curTab.url, pinned: curTab.pinned, active: false}); + }); + + const tabs = await chrome.tabs.query({windowId: newWindow.id, index: 0}); + chrome.tabs.remove(tabs[0].id); */ + } +} + +async function handleLoadWindow(windowId, tabUrl) { + // assume window is already open, give it focus + if (windowId) { + await focusWindow(windowId); + } + + // if tabUrl is defined, then focus this tab + if (tabUrl) { + const theWin = await chrome.windows.get(windowId, { populate: true }); + await focusOrLoadTabInWindow(theWin, tabUrl); + } +} + +async function focusWindow(windowId) { + await chrome.windows.update(windowId, { focused: true }); +} + +async function focusOrLoadTabInWindow(window, tabUrl) { + let match = false; + for (const tab of window.tabs) { + if (tab.url === tabUrl) { + await chrome.tabs.update(tab.id, { active: true }); + match = true; + break; + } + } + + if (!match) { + await chrome.tabs.create({ url: tabUrl }); + } +} + +/** + * Saves a new session from the specified window. + * + * @param {number} windowId - The ID of the window to save as a session + * @param {string} sessionName - The name for the new session + * @param {boolean} deleteOld - Whether to delete existing session with same name + * @returns {Promise} Promise that resolves to: + * - Session object if successfully saved + * - false if session save failed or name conflict without deleteOld + */ +async function handleSaveNewSession(windowId, sessionName, deleteOld) { + const curWindow = await chrome.windows.get(windowId, { populate: true }); + const existingSession = await dbService.fetchSessionByName(sessionName); + + // if session with same name already exist, then prompt to override the existing session + if (existingSession) { + if (!deleteOld) { + console.error( + `handleSaveNewSession: Session with name "${sessionName}" already exists and deleteOld was not true.` + ); + return false; + + // if we choose to overwrite, delete the existing session + } + await handleDeleteSession(existingSession.id); + } + const result = await spacesService.saveNewSession( + sessionName, + curWindow.tabs, + curWindow.id + ); + return result ?? false; +} + +/** + * Restores a session from backup data. + * + * @param {Space} space - The space/session data to restore + * @param {boolean} deleteOld - Whether to delete existing session with same name + * @returns {Promise} Promise that resolves to: + * - Session object if successfully restored + * - null if session restoration failed or name conflict without deleteOld + */ +async function handleRestoreFromBackup(space, deleteOld) { + const existingSession = space.name + ? await dbService.fetchSessionByName(space.name) + : false; + + // if session with same name already exist, then prompt to override the existing session + if (existingSession) { + if (!deleteOld) { + console.error( + `handleRestoreFromBackup: Session with name "${space.name}" already exists and deleteOld was not true.` + ); + return null; + } + + // if we choose to overwrite, delete the existing session + await handleDeleteSession(existingSession.id); + } + + return spacesService.saveNewSession(space.name, space.tabs, false); +} + +/** + * Imports a list of URLs as a new session with an auto-generated name. + * + * @param {string[]} urlList - Array of URLs to import as tabs + * @returns {Promise} Promise that resolves to: + * - Session object if successfully created + * - null if session creation failed + */ +async function handleImportNewSession(urlList) { + let tempName = 'Imported space: '; + let count = 1; + + while (await dbService.fetchSessionByName(tempName + count)) { + count += 1; + } + + tempName += count; + + const tabList = urlList.map(text => { + return { url: text }; + }); + + // save session to database + return spacesService.saveNewSession(tempName, tabList, false); +} + +/** + * Updates the name of an existing session. + * + * @param {number} sessionId - The ID of the session to rename + * @param {string} sessionName - The new name for the session + * @param {boolean} deleteOld - Whether to delete existing session with same name + * @returns {Promise} Promise that resolves to: + * - Session object if successfully updated + * - false if session update failed or name conflict without deleteOld + */ +async function handleUpdateSessionName(sessionId, sessionName, deleteOld) { + // check to make sure session name doesn't already exist + const existingSession = await dbService.fetchSessionByName(sessionName); + + // if session with same name already exist, then prompt to override the existing session + if (existingSession) { + if (!deleteOld) { + console.error( + `handleUpdateSessionName: Session with name "${sessionName}" already exists and deleteOld was not true.` + ); + return false; + } + + // if we choose to override, then delete the existing session + await handleDeleteSession(existingSession.id); + } + + return spacesService.updateSessionName(sessionId, sessionName) ?? false; +} + +/** + * Deletes a session from the database and removes it from the cache. + * + * @param {number} sessionId + * @returns {Promise} Promise that resolves to: + * - true if session was successfully deleted + * - false if session deletion failed or session not found + */ +async function handleDeleteSession(sessionId) { + const session = await dbService.fetchSessionById(sessionId); + if (!session) { + console.error(`handleDeleteSession: No session found with id ${sessionId}`); + return false; + } + + return spacesService.deleteSession(sessionId); +} + +/** + * @param {string} url - The URL to add to the new session + * @param {string} sessionName - The name for the new session + * @returns {Promise} Promise that resolves to: + * - Session object if the session was successfully created + * - null if a session with that name already exists or creation failed + */ +async function handleAddLinkToNewSession(url, sessionName) { + const session = await dbService.fetchSessionByName(sessionName); + const newTabs = [{ url }]; + + // if we found a session matching this name then return as an error as we are + // supposed to be creating a new session with this name + if (session) { + return null; + + // else create a new session with this name containing this url + } else { + return spacesService.saveNewSession(sessionName, newTabs, false); + } +} + +/** + * @param {number} tabId - The ID of the tab to move to the new session + * @param {string} sessionName - The name for the new session + * @returns {Promise} Promise that resolves to: + * - Session object if the session was successfully created + * - null if a session with that name already exists or creation failed + */ +async function handleMoveTabToNewSession(tabId, sessionName) { + const tab = await requestTabDetail(tabId); + if (!tab) { + return null; + } + + const session = await dbService.fetchSessionByName(sessionName); + + // if we found a session matching this name then return as an error as we are + // supposed to be creating a new session with this name + if (session) { + return null; + + // else create a new session with this name containing this tab + } else { + // remove tab from current window (should generate window events) + chrome.tabs.remove(tab.id); + + // save session to database + return spacesService.saveNewSession( + sessionName, + [tab], + false + ); + } +} + +/** + * Adds a link to an existing session. + * + * @param {string} url - The URL to add to the session + * @param {number} sessionId - The ID of the session to add the link to + * @returns {Promise} Promise that resolves to: + * - true if the link was successfully added + * - false if the session was not found or addition failed + */ +async function handleAddLinkToSession(url, sessionId) { + const session = await dbService.fetchSessionById(sessionId); + const newTabs = [{ url }]; + + // if we have not found a session matching this name then return as an error as we are + // supposed to be adding the tab to an existing session + if (!session) { + return false; + } + // if session is currently open then add link directly + if (session.windowId) { + handleAddLinkToWindow(url, session.windowId); + return true; + + // else add tab to saved session in database + } else { + // update session in db + session.tabs = session.tabs.concat(newTabs); + const result = await spacesService.updateSessionTabs(session.id, session.tabs); + return !!result; + } +} + +/** + * Adds a link to a window by creating a new tab. + * + * @param {string} url - The URL to create a tab for + * @param {number} windowId - The ID of the window to add the tab to + */ +function handleAddLinkToWindow(url, windowId) { + chrome.tabs.create({ windowId, url, active: false }); + + // NOTE: this move does not seem to trigger any tab event listeners + // so we need to update sessions manually + spacesService.queueWindowEvent(windowId); +} + +/** + * Moves a tab to an existing session. + * + * @param {number} tabId - The ID of the tab to move + * @param {number} sessionId - The ID of the session to move the tab to + * @returns {Promise} Promise that resolves to: + * - true if the tab was successfully moved + * - false if the tab or session was not found or move failed + */ +async function handleMoveTabToSession(tabId, sessionId) { + const tab = await requestTabDetail(tabId); + if (!tab) { + return false; + } + + const session = await dbService.fetchSessionById(sessionId); + const newTabs = [tab]; + + // if we have not found a session matching this name then return as an error as we are + // supposed to be adding the tab to an existing session + if (!session) { + return false; + } + + // if session is currently open then move it directly + if (session.windowId) { + moveTabToWindow(tab, session.windowId); + return true; + } + + // else add tab to saved session in database + // remove tab from current window + chrome.tabs.remove(tab.id); + + // update session in db + session.tabs = session.tabs.concat(newTabs); + return !!spacesService.updateSessionTabs(session.id, session.tabs); +} + +/** + * @param {number} tabId + * @param {number} windowId + * @returns {Promise} Promise that resolves to: + * - true if the tab was successfully moved + * - false if the tab was not found or move failed + */ +async function handleMoveTabToWindow(tabId, windowId) { + const tab = await requestTabDetail(tabId); + if (!tab) { + return false; + } + moveTabToWindow(tab, windowId); + return true; +} + +/** + * @param {chrome.tabs.Tab} tab + * @param {number} windowId The ID of the destination window. + */ +function moveTabToWindow(tab, windowId) { + chrome.tabs.move(tab.id, { windowId, index: -1 }); + + // NOTE: this move does not seem to trigger any tab event listeners + // so we need to update sessions manually + spacesService.queueWindowEvent(tab.windowId); + spacesService.queueWindowEvent(windowId); +} + +// Exports for testing. +export { cleanParameter }; \ No newline at end of file diff --git a/js/background/db.js b/js/background/db.js new file mode 100644 index 0000000..43b3faa --- /dev/null +++ b/js/background/db.js @@ -0,0 +1,559 @@ +//The MIT License +//Copyright (c) 2012 Aaron Powell +/** + * Changes in 2025 by codedread: + * - Removed unused code. + * - Modernized code style. + * - Made into an ES module. + */ + +/** @type {Object} */ +const transactionModes = { + readonly: 'readonly', + readwrite: 'readwrite', +}; + +const defaultMapper = (value) => value; + +export class Server { + /** @type {IDBDatabase} */ + db; + + /** @type {string} */ + name; + + /** @type {boolean} */ + closed; + + /** + * @param {IDBDatabase} db + * @param {string} name + */ + constructor(db, name) { + this.db = db; + this.name = name; + this.closed = false; + } + + add(table) { + if (this.closed) { + throw 'Database has been closed'; + } + + var records = []; + var counter = 0; + + for (var i = 0; i < arguments.length - 1; i++) { + if (Array.isArray(arguments[i + 1])) { + for (var j = 0; j < arguments[i + 1].length; j++) { + records[counter] = arguments[i + 1][j]; + counter++; + } + } else { + records[counter] = arguments[i + 1]; + counter++; + } + } + + var transaction = this.db.transaction(table, transactionModes.readwrite), + store = transaction.objectStore(table); + + return new Promise((resolve, reject) => { + records.forEach((record) => { + let req; + if (record.item && record.key) { + var key = record.key; + record = record.item; + req = store.add(record, key); + } else { + req = store.add(record); + } + + req.onsuccess = function(e) { + var target = e.target; + var keyPath = target.source.keyPath; + if (keyPath === null) { + keyPath = '__id__'; + } + Object.defineProperty(record, keyPath, { + value: target.result, + enumerable: true, + }); + }; + }); + + transaction.oncomplete = () => { + resolve(records, this); + }; + transaction.onerror = function(e) { + reject(e); + }; + transaction.onabort = function(e) { + reject(e); + }; + }); + } + + update(table) { + if (this.closed) { + throw 'Database has been closed'; + } + + var records = []; + for (var i = 0; i < arguments.length - 1; i++) { + records[i] = arguments[i + 1]; + } + + var transaction = this.db.transaction(table, transactionModes.readwrite), + store = transaction.objectStore(table), + keyPath = store.keyPath; + + return new Promise((resolve, reject) => { + records.forEach((record) => { + let req; + let count; + if (record.item && record.key) { + var key = record.key; + record = record.item; + req = store.put(record, key); + } else { + req = store.put(record); + } + + req.onsuccess = function(e) { + // deferred.notify(); es6 promise can't notify + }; + }); + + transaction.oncomplete = () => { + resolve(records, this); + }; + transaction.onerror = function(e) { + reject(e); + }; + transaction.onabort = function(e) { + reject(e); + }; + }); + } + + remove(table, key) { + if (this.closed) { + throw 'Database has been closed'; + } + var transaction = this.db.transaction(table, transactionModes.readwrite), + store = transaction.objectStore(table); + + return new Promise((resolve, reject) => { + var req = store['delete'](key); + transaction.oncomplete = function() { + resolve(key); + }; + transaction.onerror = function(e) { + reject(e); + }; + }); + } + + clear(table) { + if (this.closed) { + throw 'Database has been closed'; + } + var transaction = this.db.transaction(table, transactionModes.readwrite), + store = transaction.objectStore(table); + + var req = store.clear(); + return new Promise((resolve, reject) => { + transaction.oncomplete = function() { + resolve(); + }; + transaction.onerror = function(e) { + reject(e); + }; + }); + } + + close() { + if (this.closed) { + throw 'Database has been closed'; + } + this.db.close(); + this.closed = true; + delete dbCache[this.name]; + } + + get(table, id) { + if (this.closed) { + throw 'Database has been closed'; + } + var transaction = this.db.transaction(table), + store = transaction.objectStore(table); + + var req = store.get(id); + return new Promise((resolve, reject) => { + req.onsuccess = function(e) { + resolve(e.target.result); + }; + transaction.onerror = function(e) { + reject(e); + }; + }); + } + + query(table, index) { + if (this.closed) { + throw 'Database has been closed'; + } + return new IndexQuery(table, this.db, index); + } +} + +var IndexQuery = function(table, db, indexName) { + var that = this; + var modifyObj = false; + + var runQuery = function( + type, + args, + cursorType, + direction, + limitRange, + filters, + mapper + ) { + var transaction = db.transaction( + table, + modifyObj + ? transactionModes.readwrite + : transactionModes.readonly + ), + store = transaction.objectStore(table), + index = indexName ? store.index(indexName) : store, + keyRange = type ? IDBKeyRange[type].apply(null, args) : null, + results = [], + indexArgs = [keyRange], + limitRange = limitRange ? limitRange : null, + filters = filters ? filters : [], + counter = 0; + + if (cursorType !== 'count') { + indexArgs.push(direction || 'next'); + } + + // create a function that will set in the modifyObj properties into + // the passed record. + var modifyKeys = modifyObj ? Object.keys(modifyObj) : false; + var modifyRecord = function(record) { + for (var i = 0; i < modifyKeys.length; i++) { + var key = modifyKeys[i]; + var val = modifyObj[key]; + if (val instanceof Function) val = val(record); + record[key] = val; + } + return record; + }; + + index[cursorType].apply(index, indexArgs).onsuccess = function(e) { + var cursor = e.target.result; + if (typeof cursor === typeof 0) { + results = cursor; + } else if (cursor) { + if (limitRange !== null && limitRange[0] > counter) { + counter = limitRange[0]; + cursor.advance(limitRange[0]); + } else if ( + limitRange !== null && + counter >= limitRange[0] + limitRange[1] + ) { + //out of limit range... skip + } else { + var matchFilter = true; + var result = + 'value' in cursor ? cursor.value : cursor.key; + + filters.forEach(function(filter) { + if (!filter || !filter.length) { + //Invalid filter do nothing + } else if (filter.length === 2) { + matchFilter = + matchFilter && + result[filter[0]] === filter[1]; + } else { + matchFilter = + matchFilter && + filter[0].apply(undefined, [result]); + } + }); + + if (matchFilter) { + counter++; + results.push(mapper(result)); + // if we're doing a modify, run it now + if (modifyObj) { + result = modifyRecord(result); + cursor.update(result); + } + } + cursor['continue'](); + } + } + }; + + return new Promise((resolve, reject) => { + transaction.oncomplete = function() { + resolve(results); + }; + transaction.onerror = function(e) { + reject(e); + }; + transaction.onabort = function(e) { + reject(e); + }; + }); + }; + + var Query = function(type, args) { + var direction = 'next', + cursorType = 'openCursor', + filters = [], + limitRange = null, + mapper = defaultMapper, + unique = false; + + var execute = function() { + return runQuery( + type, + args, + cursorType, + unique ? direction + 'unique' : direction, + limitRange, + filters, + mapper + ); + }; + + var limit = function() { + limitRange = Array.prototype.slice.call(arguments, 0, 2); + if (limitRange.length == 1) { + limitRange.unshift(0); + } + + return { + execute: execute, + }; + }; + var count = function() { + direction = null; + cursorType = 'count'; + + return { + execute: execute, + }; + }; + var keys = function() { + cursorType = 'openKeyCursor'; + + return { + desc: desc, + execute: execute, + filter: filter, + distinct: distinct, + map: map, + }; + }; + var filter = function() { + filters.push(Array.prototype.slice.call(arguments, 0, 2)); + + return { + keys: keys, + execute: execute, + filter: filter, + desc: desc, + distinct: distinct, + modify: modify, + limit: limit, + map: map, + }; + }; + var desc = function() { + direction = 'prev'; + + return { + keys: keys, + execute: execute, + filter: filter, + distinct: distinct, + modify: modify, + map: map, + }; + }; + var distinct = function() { + unique = true; + return { + keys: keys, + count: count, + execute: execute, + filter: filter, + desc: desc, + modify: modify, + map: map, + }; + }; + var modify = function(update) { + modifyObj = update; + return { + execute: execute, + }; + }; + var map = function(fn) { + mapper = fn; + + return { + execute: execute, + count: count, + keys: keys, + filter: filter, + desc: desc, + distinct: distinct, + modify: modify, + limit: limit, + map: map, + }; + }; + + return { + execute: execute, + count: count, + keys: keys, + filter: filter, + desc: desc, + distinct: distinct, + modify: modify, + limit: limit, + map: map, + }; + }; + + 'only bound upperBound lowerBound'.split(' ').forEach(function(name) { + that[name] = function() { + return new Query(name, arguments); + }; + }); + + this.filter = function() { + var query = new Query(null, null); + return query.filter.apply(query, arguments); + }; + + this.all = function() { + return this.filter(); + }; +}; + +/** + * Creates the database schema. + * @param {Event} e + * @param {object} schema The database schema object + * @param {IDBDatabase} db + */ +function createSchema(e, schema, db) { + for (var tableName in schema) { + var table = schema[tableName]; + var store; + if ( + !Object.hasOwn(schema, tableName) || + db.objectStoreNames.contains(tableName) + ) { + store = e.currentTarget.transaction.objectStore(tableName); + } else { + store = db.createObjectStore(tableName, table.key); + } + + for (var indexKey in table.indexes) { + var index = table.indexes[indexKey]; + try { + store.index(indexKey); + } catch (e) { + store.createIndex( + indexKey, + index.key || indexKey, + Object.keys(index).length ? index : { unique: false } + ); + } + } + } +} + +/** + * Opens a connection to the database, caching it for future use. + * @param {Event} e + * @param {string} server + * @param {string} version + * @param {Object} schema + * @returns {Promise} + */ +function dbOpen(e, server, version, schema) { + var db = e.target.result; + var s = new Server(db, server); + + dbCache[server] = db; + + return Promise.resolve(s); +} + +const dbCache = {}; + +/** + * @typedef {object} DbOpenOptions + * @property {string} server The name of the database. + * @property {number} version The version of the database. + * @property {object} schema The database schema. + */ + +export const db = { + version: '0.9.2', + /** + * @param {DbOpenOptions} options + * @returns {Promise} + */ + open(options) { + /** @type {IDBOpenDBRequest} */ + var request; + + return new Promise((resolve, reject) => { + if (dbCache[options.server]) { + dbOpen( + { + target: { + result: dbCache[options.server], + }, + }, + options.server, + options.version, + options.schema + ).then(resolve, reject); + } else { + request = indexedDB.open( + options.server, + options.version + ); + + request.onsuccess = function(e) { + dbOpen( + e, + options.server, + options.version, + options.schema + ).then(resolve, reject); + }; + + request.onupgradeneeded = function(e) { + createSchema(e, options.schema, e.target.result); + }; + request.onerror = function(e) { + reject(e); + }; + } + }); + }, +}; diff --git a/js/background/dbService.js b/js/background/dbService.js new file mode 100644 index 0000000..3d899e7 --- /dev/null +++ b/js/background/dbService.js @@ -0,0 +1,231 @@ +/* global db */ + +import { db, Server } from './db.js'; + +/** + * @typedef Session + * @property {number} id Auto-generated indexedDb object id + * @property {number} sessionHash A hash formed from the combined urls in the session window + * @property {string} name The saved name of the session + * @property {Array} tabs An array of chrome tab objects (often taken from the chrome window obj) + * @property {Array} history An array of chrome tab objects that have been removed from the session + * @property {Date} lastAccess Timestamp that gets updated with every window focus + */ + +/** + * Returns database schema definition. + * @returns {Object} Database schema configuration object + */ +function getSchema() { + return { + ttSessions: { + key: { + keyPath: 'id', + autoIncrement: true, + }, + indexes: { + id: {}, + }, + }, + }; +} + +// Database constants +const DB_SERVER = 'spaces'; +const DB_VERSION = '1'; +const DB_SESSIONS = 'ttSessions'; + +class DbService { + /** + * Opens and returns a database connection. + * @private + * @returns {Promise} Promise that resolves to database connection + */ + _getDb() { + return db.open({ + server: DB_SERVER, + version: DB_VERSION, + schema: getSchema(), + }); + } + + /** + * Fetches all sessions from the database. + * @private + * @returns {Promise>} Promise that resolves to array of session objects + */ + _fetchAllSessions() { + return this._getDb().then(s => { + return s + .query(DB_SESSIONS) + .all() + .execute(); + }); + } + + /** + * Fetches a session by ID from the database. + * @private + * @param {number} id - The session ID to fetch + * @returns {Promise} Promise that resolves to session object or null if not found + */ + _fetchSessionById(id) { + return this._getDb().then(s => { + return s + .query(DB_SESSIONS, 'id') + .only(id) + .distinct() + .desc() + .execute() + .then(results => { + return results.length > 0 ? results[0] : null; + }); + }); + } + + /** + * Fetches all sessions from the database. + * @returns {Promise>} Promise that resolves to array of session objects + */ + async fetchAllSessions() { + try { + const sessions = await this._fetchAllSessions(); + return sessions; + } catch (error) { + console.error('Error fetching all sessions:', error); + return []; + } + } + + /** + * Fetches a session by ID. + * @param {string|number} id - The session ID to fetch + * @returns {Promise} Promise that resolves to session object or null if not found + */ + async fetchSessionById(id) { + const _id = typeof id === 'string' ? parseInt(id, 10) : id; + try { + const session = await this._fetchSessionById(_id); + return session; + } catch (error) { + console.error('Error fetching session by ID:', error); + return null; + } + } + + /** + * Fetches all session names. Not used today. + * @private + * @returns {Promise>} Promise that resolves to array of session names + */ + async _fetchSessionNames() { + try { + const sessions = await this._fetchAllSessions(); + return sessions.map(session => session.name); + } catch (error) { + console.error('Error fetching session names:', error); + return []; + } + } + + /** + * Fetches a session by window ID. + * @param {number} windowId - The window ID to search for + * @returns {Promise} Promise that resolves to session object or false if not found + */ + async fetchSessionByWindowId(windowId) { + try { + const sessions = await this._fetchAllSessions(); + const matchedSession = sessions.find(session => session.windowId === windowId); + return matchedSession || false; + } catch (error) { + console.error('Error fetching session by window ID:', error); + return false; + } + } + + /** + * Fetches a session by name. + * @param {string} sessionName - The session name to search for + * @returns {Promise} Promise that resolves to session object or false if not found + */ + async fetchSessionByName(sessionName) { + try { + const sessions = await this._fetchAllSessions(); + let matchIndex; + const matchFound = sessions.some((session, index) => { + if (session.name?.toLowerCase() === sessionName.toLowerCase()) { + matchIndex = index; + return true; + } + return false; + }); + + return matchFound ? sessions[matchIndex] : false; + } catch (error) { + console.error('Error fetching session by name:', error); + return false; + } + } + + /** + * Creates a new session in the database. + * @param {Session} session - The session object to create (id will be auto-generated) + * @returns {Promise} Promise that resolves to created session with ID or null if failed + */ + async createSession(session) { + // delete session id in case it already exists + const { id, ..._session } = session; + + try { + const s = await this._getDb(); + const result = await s.add(DB_SESSIONS, _session); + return result.length > 0 ? result[0] : null; + } catch (error) { + console.error('Error creating session:', error); + return null; + } + } + + /** + * Updates an existing session in the database. + * @param {Session} session - The session object to update (must have valid id) + * @returns {Promise} Promise that resolves to updated session or null if failed + */ + async updateSession(session) { + // ensure session id is set + if (!session.id) { + return null; + } + + try { + const s = await this._getDb(); + const result = await s.update(DB_SESSIONS, session); + return result.length > 0 ? result[0] : null; + } catch (error) { + console.error('Error updating session:', error); + return null; + } + } + + /** + * Removes a session from the database. + * @param {string|number} id - The session ID to remove + * @returns {Promise} Promise that resolves to true if successful, false if failed + */ + async removeSession(id) { + const _id = typeof id === 'string' ? parseInt(id, 10) : id; + + try { + const s = await this._getDb(); + await s.remove(DB_SESSIONS, _id); + return true; + } catch (error) { + console.error('Error removing session:', error); + return false; + } + } +} + +// Export an instantiated object +export const dbService = new DbService(); diff --git a/js/background/main.js b/js/background/main.js new file mode 100644 index 0000000..3c1e66b --- /dev/null +++ b/js/background/main.js @@ -0,0 +1,3 @@ +import { initializeServiceWorker } from './background.js'; + +initializeServiceWorker(); diff --git a/js/background/spacesService.js b/js/background/spacesService.js new file mode 100644 index 0000000..8aae49c --- /dev/null +++ b/js/background/spacesService.js @@ -0,0 +1,1144 @@ +/* global chrome, dbService */ + +/* spaces + * Copyright (C) 2015 Dean Oemcke + */ + +/** @typedef {import('./dbService.js').Session} Session */ + +import { dbService } from './dbService.js'; + +// Module-level properties +const debug = false; +const noop = () => {}; + +class SpacesService { + /** + * Array containing all sessions - both saved sessions from database and temporary sessions for open windows. + * Saved sessions have an `id` property, while temporary sessions have `id: false` and represent + * open windows that don't match any saved session. + * @type {Array} + * @private + */ + sessions = []; + + constructor() { + this.tabHistoryUrlMap = {}; + this.closedWindowIds = {}; + this.sessionUpdateTimers = {}; + this.historyQueue = []; + this.eventQueueCount = 0; + this.initialized = false; + this.initializationPromise = null; + } + + // Ensure spacesService is initialized before processing events + async ensureInitialized() { + if (this.initialized) { + return; + } + + if (this.initializationPromise) { + await this.initializationPromise; + return; + } + + this.initializationPromise = this.initialiseSpaces().then(async () => { + await this._initialiseTabHistory(); + this.initialized = true; + this.initializationPromise = null; + }); + + await this.initializationPromise; + } + + // initialise spaces - combine open windows with saved sessions + async initialiseSpaces() { + this.initialized = false; // Reset on re-initialization + + // update version numbers + const lastVersion = await this.fetchLastVersion(); + this.setLastVersion(chrome.runtime.getManifest().version); + + try { + const sessions = await dbService.fetchAllSessions(); + + if ( + chrome.runtime.getManifest().version === '0.18' && + chrome.runtime.getManifest().version !== lastVersion + ) { + await this.resetAllSessionHashes(sessions); + } + + const windows = await chrome.windows.getAll({ populate: true }); + // populate session map from database + this.sessions = sessions; + + // then try to match current open windows with saved sessions + for (const curWindow of windows) { + if (!filterInternalWindows(curWindow)) { + await this._checkForSessionMatchDuringInit(curWindow); + } + } + + // Initialization complete + this.initialized = true; + } catch (error) { + console.error('Error initializing spaces:', error); + this.initialized = false; + } + } + + // Clear windowId associations after Chrome restart (when window IDs get reassigned) + async clearWindowIdAssociations() { + try { + const sessions = await dbService.fetchAllSessions(); + + // clear any previously saved windowIds both in memory and database + for (const session of sessions) { + if (session.windowId) { + session.windowId = false; + // Persist the cleared windowId to database and update memory + await this._updateSessionSync(session); + } + } + + // Also clear from in-memory cache if it's already loaded + if (this.sessions && this.sessions.length > 0) { + for (const session of this.sessions) { + if (session.windowId) { + session.windowId = false; + } + } + } + } catch (error) { + console.error('Error clearing window ID associations:', error); + } + } + + async resetAllSessionHashes(sessions) { + for (const session of sessions) { + // eslint-disable-next-line no-param-reassign + session.sessionHash = generateSessionHash( + session.tabs + ); + await this._updateSessionSync(session); + } + } + + /** + * Record each tab's id and url so we can add history items when tabs are removed + * @private + */ + async _initialiseTabHistory() { + const tabs = await chrome.tabs.query({}); + for (const tab of tabs) { + this.tabHistoryUrlMap[tab.id] = tab.url; + } + } + + /** + * Checks for session matches during initialization, handling both service worker reactivation + * and Chrome restart scenarios. First checks for existing sessions by windowId, then falls + * back to hash matching if none found. + * + * @private + * @param {chrome.windows.Window} curWindow - Chrome window object with tabs array + * @returns {Promise} Resolves when initialization matching completes + */ + async _checkForSessionMatchDuringInit(curWindow) { + if (!curWindow.tabs || curWindow.tabs.length === 0) { + return; + } + + // First, check if there's already a session with this windowId (service worker reactivation case) + let existingSession = null; + try { + existingSession = await this._getSessionByWindowIdInternal(curWindow.id); + } catch (error) { + console.error('Error fetching session by windowId:', error); + } + + if (existingSession) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `existing session found for windowId: ${curWindow.id}. session: ${existingSession.id || 'temporary'}` + ); + } + // Session already exists for this window, no need to create or match anything + return; + } + + // If no existing session, fall back to hash matching (Chrome restart case) + await this._ensureWindowHasSession(curWindow); + } + + /** + * Ensures a window has an associated session using multiple strategies: + * 1. Checks for existing session by windowId (returns early if found) + * 2. Attempts hash-based matching with saved sessions (links if match found) + * 3. Creates temporary session as fallback (if no existing session or match) + * + * @private + * @param {chrome.windows.Window} curWindow - Chrome window object with tabs array + * @returns {Promise} Resolves when session association is complete + */ + async _ensureWindowHasSession(curWindow) { + if (!curWindow.tabs || curWindow.tabs.length === 0) { + return; + } + + // Double-check that a session doesn't already exist for this window + // This is an additional safety check to prevent race conditions + const existingSession = this.sessions.find(session => session.windowId === curWindow.id); + if (existingSession) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `ensureWindowHasSession: Session already exists for window ${curWindow.id}, skipping creation` + ); + } + return; + } + + // Generate hash from current window's tabs to find matching saved sessions + const sessionHash = generateSessionHash(curWindow.tabs); + + // Find matching session by hash (closedOnly = true - sessions with no windowId) + let matchingSession = false; + try { + const sessions = await dbService.fetchAllSessions(); + const matchedSession = sessions.find(session => { + return session.sessionHash === sessionHash && !session.windowId; + }); + matchingSession = matchedSession || false; + } catch (error) { + console.error('Error fetching session by hash:', error); + matchingSession = false; + } + + if (matchingSession) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `matching session found: ${matchingSession.id}. linking with window: ${curWindow.id}` + ); + } + + this.matchSessionToWindow(matchingSession, curWindow); + } else { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `no matching session found. creating temporary session for window: ${curWindow.id}` + ); + } + + // create a new temporary session for this window (with no sessionId or name) + this._createTemporaryUnmatchedSession(curWindow); + } + } + + async matchSessionToWindow(session, curWindow) { + await this.ensureInitialized(); + + // remove any other sessions tied to this windowId (temporary sessions) + for (let i = this.sessions.length - 1; i >= 0; i -= 1) { + if (this.sessions[i].windowId === curWindow.id) { + if (this.sessions[i].id) { + this.sessions[i].windowId = false; + // Persist the cleared windowId to database with sync + await this._updateSessionSync(this.sessions[i]); + } else { + this.sessions.splice(i, 1); + } + } + } + + // assign windowId to newly matched session + // eslint-disable-next-line no-param-reassign + session.windowId = curWindow.id; + + // Persist the new windowId association to database with automatic sync + if (session.id) { + await this._updateSessionSync(session); + } + } + + /** + * Safely adds a session to this.sessions array, preventing duplicates. + * For saved sessions (with id), prevents duplicates by id. + * For any session with windowId, prevents duplicates by windowId. + * + * @private + * @param {Session} newSession - The session to add + * @returns {boolean} True if session was added, false if duplicate was prevented + */ + _addSessionSafely(newSession) { + // For saved sessions (with id), check for ID duplicates + if (newSession.id) { + const existingSession = this.sessions.find(session => session.id === newSession.id); + if (existingSession) { + console.error( + `_addSessionSafely: Attempted to add duplicate session with id ${newSession.id}. This should not happen!` + ); + return false; + } + } + + // For any session with windowId, check for windowId duplicates + if (newSession.windowId) { + const existingSession = this.sessions.find(session => session.windowId === newSession.windowId); + if (existingSession) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `_addSessionSafely: Session already exists for window ${newSession.windowId}, skipping addition` + ); + } + return false; + } + } + + // Safe to add - no duplicate found + this.sessions.push(newSession); + return true; + } + + /** + * Creates a temporary session for an unmatched window that doesn't correspond to any saved session. + * Temporary sessions have `id: false` and represent open windows that haven't been saved as sessions yet. + * Uses centralized duplicate prevention to ensure no duplicate sessions are created for the same windowId. + * + * @private + * @param {chrome.windows.Window} curWindow - The Chrome window object to create a temporary session for + * @returns {boolean} True if the temporary session was successfully created, false if duplicate was prevented + * + * @example + * // Internal usage only + * const window = { id: 123, tabs: [{ url: 'https://example.com' }] }; + * const created = this._createTemporaryUnmatchedSession(window); + * console.log(created); // true if session created, false if duplicate prevented + */ + _createTemporaryUnmatchedSession(curWindow) { + if (debug) { + // eslint-disable-next-line no-console + console.dir(this.sessions); + // eslint-disable-next-line no-console + console.dir(curWindow); + // eslint-disable-next-line no-alert + // alert('couldnt match window. creating temporary session'); + } + + const sessionHash = generateSessionHash(curWindow.tabs); + + const newSession = { + id: false, + windowId: curWindow.id, + sessionHash, + name: false, + tabs: curWindow.tabs, + history: [], + lastAccess: new Date(), + }; + + // Use centralized method to prevent duplicates + return this._addSessionSafely(newSession); + } + + // local storage getters/setters + async fetchLastVersion() { + let version = await chrome.storage.local.get(['spacesVersion']); + if (version !== null && version['spacesVersion']) { + version = JSON.parse(version['spacesVersion']); + return version; + } + return 0; + } + + setLastVersion(newVersion) { + chrome.storage.local.set({'spacesVersion': JSON.stringify(newVersion)}); + } + + /** + * Get all sessions (includes both saved sessions and temporary open window sessions) + * @returns {Promise>} Promise that resolves to a shallow copy of all sessions + */ + async getAllSessions() { + await this.ensureInitialized(); + return [...(this.sessions || [])]; + } + + /** + * Find a session by windowId, checking both in-memory sessions and database + * @param {number} windowId - The window ID to search for + * @returns {Session|null} The session object if found, null otherwise + */ + async getSessionByWindowId(windowId) { + await this.ensureInitialized(); + return await this._getSessionByWindowIdInternal(windowId); + } + + /** + * Internal method to find a session by windowId without ensuring initialization + * Used during initialization to avoid circular dependencies + * @private + * @param {number} windowId - The window ID to search for + * @returns {Promise} The session object if found, null otherwise + */ + async _getSessionByWindowIdInternal(windowId) { + // First check in-memory sessions (includes temporary sessions) + const memorySession = this.sessions.find(session => session.windowId === windowId); + if (memorySession) { + return memorySession; + } + + // If not found in memory, check database (for saved sessions) + // During initialization, avoid potential circular dependencies + if (this.initialized) { + const dbSession = await dbService.fetchSessionByWindowId(windowId); + return dbSession || null; + } + + // During initialization, only check what's already loaded in memory + return null; + } + + // event listener functions for window and tab events + // (events are received and screened first in background.js) + // ----------------------------------------------------------------------------------------- + + async handleTabRemoved(tabId, removeInfo, callback) { + await this.ensureInitialized(); + + if (debug) { + // eslint-disable-next-line no-console + console.log( + `handlingTabRemoved event. windowId: ${removeInfo.windowId}` + ); + } + + // NOTE: isWindowClosing is true if the window cross was clicked causing the tab to be removed. + // If the tab cross is clicked and it is the last tab in the window + // isWindowClosing will still be false even though the window will close + if (removeInfo.isWindowClosing) { + // be very careful here as we definitley do not want these removals being saved + // as part of the session (effectively corrupting the session) + + // should be handled by the window removed listener + this.handleWindowRemoved( + removeInfo.windowId, + true + ); + + // if this is a legitimate single tab removal from a window then update session/window + } else { + this.historyQueue.push({ + url: this.tabHistoryUrlMap[tabId], + windowId: removeInfo.windowId, + action: 'add', + }); + this.queueWindowEvent( + removeInfo.windowId, + this.eventQueueCount, + callback + ); + + // remove tab from tabHistoryUrlMap + delete this.tabHistoryUrlMap[tabId]; + } + } + + async handleTabMoved(tabId, moveInfo, callback) { + await this.ensureInitialized(); + + if (debug) { + // eslint-disable-next-line no-console + console.log( + `handlingTabMoved event. windowId: ${moveInfo.windowId}` + ); + } + this.queueWindowEvent( + moveInfo.windowId, + this.eventQueueCount, + callback + ); + } + + async handleTabUpdated(tab, changeInfo, callback) { + await this.ensureInitialized(); + + // NOTE: only queue event when tab has completed loading (title property exists at this point) + if (tab.status === 'complete') { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `handlingTabUpdated event. windowId: ${tab.windowId}` + ); + } + + // update tab history in case the tab url has changed + this.tabHistoryUrlMap[tab.id] = tab.url; + this.queueWindowEvent( + tab.windowId, + this.eventQueueCount, + callback + ); + } + + // check for change in tab url. if so, update history + if (changeInfo.url) { + // add tab to history queue as an item to be removed (as it is open for this window) + this.historyQueue.push({ + url: changeInfo.url, + windowId: tab.windowId, + action: 'remove', + }); + } + } + + /** + * Handles window removal events by cleaning up session data and managing window state. + * Updates session associations and removes temporary sessions when windows are closed. + * + * @param {number} windowId - The ID of the window that was removed + * @param {boolean} markAsClosed - Whether to mark this window as permanently closed + * @returns {Promise} Promise that resolves to: + * - true if the window removal was successfully processed + * - false if the removal was ignored (duplicate event for same windowId) + */ + async handleWindowRemoved(windowId, markAsClosed) { + await this.ensureInitialized(); + + // ignore subsequent windowRemoved events for the same windowId (each closing tab will try to call this) + if (this.closedWindowIds[windowId]) { + return true; + } + + if (debug) { + // eslint-disable-next-line no-console + console.log(`handlingWindowRemoved event. windowId: ${windowId}`); + } + + // add windowId to closedWindowIds. the idea is that once a window is closed it can never be + // rematched to a new session (hopefully these window ids never get legitimately re-used) + if (markAsClosed) { + if (debug) { + // eslint-disable-next-line no-console + console.log(`adding window to closedWindowIds: ${windowId}`); + } + + this.closedWindowIds[windowId] = true; + clearTimeout(this.sessionUpdateTimers[windowId]); + } + + const session = await this.getSessionByWindowId(windowId); + if (session) { + // if this is a saved session then just remove the windowId reference + if (session.id) { + session.windowId = false; + // Persist the cleared windowId to database with sync + await this._updateSessionSync(session); + + // else if it is temporary session then remove the session from the cache + } else { + this.sessions.some((curSession, index) => { + if (curSession.windowId === windowId) { + this.sessions.splice(index, 1); + return true; + } + return false; + }); + } + } + + return true; + } + + async handleWindowFocussed(windowId) { + await this.ensureInitialized(); + + if (debug) { + // eslint-disable-next-line no-console + console.log(`handleWindowFocussed event. windowId: ${windowId}`); + } + + if (windowId <= 0) { + return; + } + + const session = await this.getSessionByWindowId(windowId); + if (session) { + session.lastAccess = new Date(); + } + } + + // 1sec timer-based batching system. + // Set a timeout so that multiple tabs all opened at once (like when restoring a session) + // only trigger this function once (as per the timeout set by the last tab event) + // This will cause multiple triggers if time between tab openings is longer than 1 sec + queueWindowEvent(windowId, eventId, callback = noop) { + clearTimeout(this.sessionUpdateTimers[windowId]); + + this.eventQueueCount += 1; + + this.sessionUpdateTimers[windowId] = setTimeout(async () => { + const shouldCallback = await this.handleWindowEvent(windowId, eventId); + if (shouldCallback) callback(); + }, 1000); + } + + /** + * Handles window events by updating session data when tabs change within a window. + * This function processes batched tab events and updates the corresponding session + * in the database. + * + * NOTE: Careful here as this function gets called A LOT + * + * @param {number} windowId - The ID of the window that triggered the event + * @param {number} eventId - The unique event identifier for tracking/debugging purposes + * @returns {Promise} Promise that resolves to: + * - true if the window event was successfully processed + * - false if the event was ignored (invalid window, internal window, closed window, etc.) + */ + async handleWindowEvent(windowId, eventId) { + if (debug) { + // eslint-disable-next-line no-console + console.log('------------------------------------------------'); + + // eslint-disable-next-line no-console + console.log( + `event: ${eventId}. attempting session update. windowId: ${windowId}` + ); + } + + // sanity check windowId + if (!windowId || windowId <= 0) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `received an event for windowId: ${windowId} which is obviously wrong` + ); + } + return false; + } + + let curWindow; + try { + curWindow = await chrome.windows.get(windowId, { populate: true }); + } catch (e) { + // eslint-disable-next-line no-console + console.log( + `${e.message}. perhaps its the development console???` + ); + + // if we can't find this window, then better remove references to it from the cached sessions + // don't mark as a removed window however, so that the space can be resynced up if the window + // does actually still exist (for some unknown reason) + this.handleWindowRemoved( + windowId, + false + ); + return false; + } + + if (!curWindow || filterInternalWindows(curWindow)) { + return false; + } + + // don't allow event if it pertains to a closed window id + if (this.closedWindowIds[windowId]) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `ignoring event as it pertains to a closed windowId: ${windowId}` + ); + } + return false; + } + + // if window is associated with an open session then update session + const session = await this.getSessionByWindowId(windowId); + + if (session) { + if (debug) { + // eslint-disable-next-line no-console + console.log( + `tab statuses: ${curWindow.tabs + .map(curTab => { + return curTab.status; + }) + .join('|')}` + ); + } + + // look for tabs recently added/removed from this session and update session history + const historyItems = this.historyQueue.filter( + historyItem => { + return historyItem.windowId === windowId; + } + ); + + for (let i = historyItems.length - 1; i >= 0; i -= 1) { + const historyItem = historyItems[i]; + + if (historyItem.action === 'add') { + this.addUrlToSessionHistory( + session, + historyItem.url + ); + } else if (historyItem.action === 'remove') { + this.removeUrlFromSessionHistory( + session, + historyItem.url + ); + } + this.historyQueue.splice(i, 1); + } + + // override session tabs with tabs from window + session.tabs = curWindow.tabs; + session.sessionHash = generateSessionHash(session.tabs); + + // if it is a saved session then update db + if (session.id) { + await this.saveExistingSession(session); + } + } + + // if no session found, it must be a new window - ensure it has a session + // Note: if session found without session.id, it's a temporary session and we should NOT + // call _ensureWindowHasSession as that would create duplicate temporary sessions + if (!session) { + if (debug) { + // eslint-disable-next-line no-console + console.log('session check triggered'); + } + this._ensureWindowHasSession(curWindow); + } + return true; + } + + // PUBLIC FUNCTIONS + + addUrlToSessionHistory(session, newUrl) { + if (debug) { + // eslint-disable-next-line no-console + console.log(`adding tab to history: ${newUrl}`); + } + + const cleanUrlResult = cleanUrl(newUrl); + + if (cleanUrlResult.length === 0) { + return false; + } + + // don't add removed tab to history if there is still a tab open with same url + // note: assumes tab has NOT already been removed from session.tabs + const tabBeingRemoved = session.tabs.filter(curTab => { + return cleanUrl(curTab.url) === cleanUrlResult; + }); + + if (tabBeingRemoved.length !== 1) { + return false; + } + + // eslint-disable-next-line no-param-reassign + if (!session.history) session.history = []; + + // see if tab already exists in history. if so then remove it (it will be re-added) + session.history.some((historyTab, index) => { + if (cleanUrl(historyTab.url) === cleanUrlResult) { + session.history.splice(index, 1); + return true; + } + return false; + }); + + // add url to session history + // eslint-disable-next-line no-param-reassign + session.history = tabBeingRemoved.concat(session.history); + + // trim history for this space down to last 200 items + // eslint-disable-next-line no-param-reassign + session.history = session.history.slice(0, 200); + + return session; + } + + removeUrlFromSessionHistory(session, newUrl) { + if (debug) { + // eslint-disable-next-line no-console + console.log(`removing tab from history: ${newUrl}`); + } + + // eslint-disable-next-line no-param-reassign + newUrl = cleanUrl(newUrl); + + if (newUrl.length === 0) { + return; + } + + // see if tab already exists in history. if so then remove it + session.history.some((historyTab, index) => { + if (cleanUrl(historyTab.url) === newUrl) { + session.history.splice(index, 1); + return true; + } + return false; + }); + } + + // Database actions + + /** + * Updates the tabs of an existing session in the database. + * + * @param {number} sessionId - The ID of the session to update + * @param {Array} tabs - Array of tab objects containing URL and other tab properties + * @returns {Promise} Promise that resolves to: + * - Updated session object if successfully saved + * - null if session update failed + */ + async updateSessionTabs(sessionId, tabs) { + const session = await dbService.fetchSessionById(sessionId); + + // update tabs in session + session.tabs = tabs; + session.sessionHash = generateSessionHash(session.tabs); + + return this.saveExistingSession(session); + } + + /** + * Updates the name of an existing session in the database. + * + * @param {number} sessionId - The ID of the session to update + * @param {string} sessionName - The new name for the session + * @returns {Promise} Promise that resolves to: + * - Updated session object if successfully saved + * - null if session update failed + */ + async updateSessionName(sessionId, sessionName) { + const session = await dbService.fetchSessionById(sessionId); + session.name = sessionName; + + return this.saveExistingSession(session); + } + + /** + * Updates an existing session in the database. + * + * @param {Session} session - The session object to update + * @returns {Promise} Promise that resolves to: + * - Updated session object if successfully saved + * - null if session update failed + */ + async saveExistingSession(session) { + try { + const updatedSession = await this._updateSessionSync(session); + return updatedSession || null; + } catch (error) { + console.error('Error saving existing session:', error); + return null; + } + } + + /** + * Creates a new session with the provided name, tabs, and window association. + * If a temporary session exists for the given windowId, it will be converted to a permanent session. + * Otherwise, a new session is created and added to the sessions cache. + * + * IMPORTANT: This method only works with temporary sessions (id: false). It will reject any + * attempt to "create" a session that already has a saved ID to prevent data corruption. + * + * @param {string} sessionName - The name for the new session + * @param {Array} tabs - Array of tab objects containing URL and other tab properties + * @param {number|false} windowId - The window ID to associate with this session, or false for no association + * @returns {Promise} Promise that resolves to: + * - Session object with id property if successfully created + * - null if session creation failed, no tabs were provided, or attempted on already saved session + */ + async saveNewSession(sessionName, tabs, windowId) { + await this.ensureInitialized(); + + if (!tabs) { + return null; + } + + const sessionHash = generateSessionHash(tabs); + let session; + + // check for a temporary session with this windowId + if (windowId) { + const existingSession = await this.getSessionByWindowId(windowId); + if (existingSession) { + // If it's a saved session, reject immediately to prevent data corruption + if (existingSession.id) { + console.error('Cannot create new session: window already has a saved session'); + return null; + } + // Only use the session if it's temporary (no id) + session = existingSession; + } + } + + // if no existing session found, create a new one + if (!session) { + session = { + windowId, + history: [], + }; + // Use centralized method to prevent duplicates (protects against race conditions) + const wasAdded = this._addSessionSafely(session); + if (!wasAdded) { + // Race condition: another async operation created a session for this windowId + // Retrieve the session that was created by the other operation + const raceConditionSession = await this.getSessionByWindowId(windowId); + if (!raceConditionSession) { + console.error('Race condition detected but failed to retrieve the competing session'); + return null; + } + session = raceConditionSession; + } + } + + // update temporary session details + session.name = sessionName; + session.sessionHash = sessionHash; + session.tabs = tabs; + session.lastAccess = new Date(); + + // save session to db - this should only be called on temporary sessions (id: false) + try { + const savedSession = await this._createSessionSync(session); + if (savedSession) { + return savedSession; + } else { + console.error('Failed to create session'); + return null; + } + } catch (error) { + console.error('Error creating session:', error); + return null; + } + } + + // ======================================== + // CENTRALIZED DATABASE OPERATIONS + // These methods handle both database operations AND memory synchronization + // ======================================== + + /** + * Creates a session in the database and ensures memory cache synchronization. + * @private + * @param {Session} session - Session object to create (must exist in this.sessions) + * @returns {Promise} The created session with ID, or null if failed + */ + async _createSessionSync(session) { + try { + const savedSession = await dbService.createSession(session); + if (savedSession) { + // Find and update the session in memory cache + const index = this.sessions.findIndex(s => s === session); + if (index !== -1) { + // Update the existing object in place to preserve references + // This is critical for UI components that hold references to session objects + Object.assign(this.sessions[index], savedSession); + return this.sessions[index]; + } else { + console.warn('Session not found in memory cache during create sync'); + return savedSession; + } + } + return null; + } catch (error) { + console.error('Error creating session with sync:', error); + return null; + } + } + + /** + * Updates a session in the database and ensures memory cache synchronization. + * @private + * @param {Session} session - Session object to update (must have valid id) + * @returns {Promise} The updated session, or null if failed + */ + async _updateSessionSync(session) { + try { + const updatedSession = await dbService.updateSession(session); + if (updatedSession) { + // Find and update the session in memory cache + const index = this.sessions.findIndex(s => s.id === session.id); + if (index !== -1) { + // Update the existing object in place to preserve references + // This is critical for UI components that hold references to session objects + Object.assign(this.sessions[index], updatedSession); + return this.sessions[index]; + } else { + console.warn('Session not found in memory cache during update sync'); + return updatedSession; + } + } + return null; + } catch (error) { + console.error('Error updating session with sync:', error); + return null; + } + } + + /** + * Deletes a session from the database and removes it from the cache. + * + * @param {number} sessionId - The ID of the session to delete + * @returns {Promise} Promise that resolves to: + * - true if session was successfully deleted + * - false if session deletion failed + */ + async deleteSession(sessionId) { + try { + const success = await dbService.removeSession(sessionId); + if (success) { + // remove session from cached array + this.sessions.some((session, index) => { + if (session.id === sessionId) { + this.sessions.splice(index, 1); + return true; + } + return false; + }); + } + return success; + } catch (error) { + console.error('Error deleting session:', error); + return false; + } + } +} + +// Module-level helper functions. + +/** + * Cleans and normalizes a URL by removing query parameters, fragments, and filtering out + * internal Chrome extension URLs and new tab pages. Also handles special cases like + * 'The Great Suspender' extension URLs. + * + * NOTE: if ever changing this function, then we'll need to update all + * saved sessionHashes so that they match next time, using: resetAllSessionHashes() + * + * @param {string} url - The URL to clean and normalize + * @returns {string} The cleaned URL, or empty string if URL should be ignored + * + * @example + * cleanUrl('https://example.com/page?param=value#section') // returns 'https://example.com/page' + * cleanUrl('chrome://newtab/') // returns '' + * cleanUrl('chrome-extension://abc123/page.html') // returns '' + */ +function cleanUrl(url) { + if (!url) { + return ''; + } + + // ignore urls from this extension + if (url.indexOf(chrome.runtime.id) >= 0) { + return ''; + } + + // ignore 'new tab' pages + if (url.indexOf('chrome:// newtab/') >= 0) { + return ''; + } + + let processedUrl = url; + + // add support for 'The Great Suspender' + if ( + processedUrl.indexOf('suspended.html') > 0 && + processedUrl.indexOf('uri=') > 0 + ) { + processedUrl = processedUrl.substring( + processedUrl.indexOf('uri=') + 4, + processedUrl.length + ); + } + + // remove any text after a '#' symbol + if (processedUrl.indexOf('#') > 0) { + processedUrl = processedUrl.substring(0, processedUrl.indexOf('#')); + } + + // remove any text after a '?' symbol + if (processedUrl.indexOf('?') > 0) { + processedUrl = processedUrl.substring(0, processedUrl.indexOf('?')); + } + + return processedUrl; +} + +/** + * Filters out internal Chrome extension windows that should be ignored by the Spaces extension. + * This includes windows containing only the Spaces extension's own pages, as well as popup + * and panel type windows. + * + * @param {chrome.windows.Window} curWindow - The Chrome window object to check + * @returns {boolean} True if the window should be filtered out (ignored), false otherwise + * + * @example + * filterInternalWindows({ tabs: [{ url: 'chrome-extension://abc123/spaces.html' }], type: 'normal' }) // returns true + * filterInternalWindows({ tabs: [{ url: 'https://example.com' }], type: 'popup' }) // returns true + * filterInternalWindows({ tabs: [{ url: 'https://example.com' }], type: 'normal' }) // returns false + */ +function filterInternalWindows(curWindow) { + // sanity check to make sure window isnt an internal spaces window + if ( + curWindow.tabs.length === 1 && + curWindow.tabs[0].url.indexOf(chrome.runtime.id) >= 0 + ) { + return true; + } + + // also filter out popup or panel window types + if (curWindow.type === 'popup' || curWindow.type === 'panel') { + return true; + } + return false; +} + +/** + * Generates a unique hash for a browser session based on the URLs of its tabs. + * This hash is used to match existing sessions when windows are reopened after Chrome restart. + * The hash is created by concatenating all cleaned tab URLs and applying a 32-bit hash algorithm. + * + * @param {Array} tabs - Array of tab objects, each containing a 'url' property + * @returns {number} A positive 32-bit integer hash representing the session + * + * @example + * const tabs = [ + * { url: 'https://example.com' }, + * { url: 'https://google.com' } + * ]; + * generateSessionHash(tabs) // returns something like 1234567890 + */ +function generateSessionHash(tabs) { + const text = tabs.reduce((prevStr, tab) => { + return prevStr + cleanUrl(tab.url); + }, ''); + + let hash = 0; + if (text.length === 0) return hash; + for (let i = 0, len = text.length; i < len; i += 1) { + const chr = text.charCodeAt(i); + // eslint-disable-next-line no-bitwise + hash = (hash << 5) - hash + chr; + // eslint-disable-next-line no-bitwise + hash |= 0; // Convert to 32bit integer + } + return Math.abs(hash); +} + +// Export an instance of the SpacesService class +export const spacesService = new SpacesService(); + +// Export helper functions for testing +export { cleanUrl, filterInternalWindows, generateSessionHash }; diff --git a/js/common.js b/js/common.js new file mode 100644 index 0000000..3b1a058 --- /dev/null +++ b/js/common.js @@ -0,0 +1,59 @@ +/** + * @fileoverview Shared utilities and types for the Spaces Chrome extension. + * + * This module contains functions and type definitions that are used by both + * client-side code (popup, spaces window, etc.) and background scripts. + * Client-side only utilities should be placed in utils.js instead. + * + * Licensed under the MIT License + * Copyright (C) 2025 by the Contributors. + */ + +/** + * @typedef Space + * @property {number|false} sessionId The unique identifier for the session, or false if not saved. + * @property {number|false} windowId The ID of the window associated with the space, or false if not open. + * @property {string|false} name The name of the space, or false if not named. + * @property {Array} tabs Array of tab objects containing URL and other tab properties. + * @property {Array|false} history Array of tab history objects, or false if no history. + */ + +/** + * @typedef SessionPresence + * @property {boolean} exists A session with this name exists in the database. + * @property {boolean} isOpen The session is currently open in a window. + */ + +/** + * Extracts a parameter value from a URL's hash fragment. + * @param {string} key - The parameter name to extract + * @param {string} urlStr - The URL string to parse + * @returns {string|false} The parameter value, or false if not found + * + * @example + * getHashVariable('id', 'https://example.com#id=123&name=test') + * // returns: '123' + */ +export function getHashVariable(key, urlStr) { + const valuesByKey = {}; + const keyPairRegEx = /^(.+)=(.+)/; + + if (!urlStr || urlStr.length === 0 || urlStr.indexOf('#') === -1) { + return false; + } + + // extract hash component from url + const hashStr = urlStr.replace(/^[^#]+#+(.*)/, '$1'); + if (hashStr.length === 0) { + return false; + } + + hashStr.split('&').forEach(keyPair => { + if (keyPair && keyPair.match(keyPairRegEx)) { + valuesByKey[ + keyPair.replace(keyPairRegEx, '$1') + ] = keyPair.replace(keyPairRegEx, '$2'); + } + }); + return valuesByKey[key] || false; +} diff --git a/js/db.js b/js/db.js deleted file mode 100644 index ebdf5b3..0000000 --- a/js/db.js +++ /dev/null @@ -1,608 +0,0 @@ -//The MIT License -//Copyright (c) 2012 Aaron Powell - -(function(window, undefined) { - 'use strict'; - - var indexedDB, - IDBKeyRange = window.IDBKeyRange || window.webkitIDBKeyRange, - transactionModes = { - readonly: 'readonly', - readwrite: 'readwrite', - }; - - var hasOwn = Object.prototype.hasOwnProperty; - - var getIndexedDB = function() { - if (!indexedDB) { - indexedDB = - window.indexedDB || - window.webkitIndexedDB || - window.mozIndexedDB || - window.oIndexedDB || - window.msIndexedDB; - - if (!indexedDB) { - throw 'IndexedDB required'; - } - } - return indexedDB; - }; - - var defaultMapper = function(value) { - return value; - }; - - var CallbackList = function() { - var state, - list = []; - - var exec = function(context, args) { - if (list) { - args = args || []; - state = state || [context, args]; - - for (var i = 0, il = list.length; i < il; i++) { - list[i].apply(state[0], state[1]); - } - - list = []; - } - }; - - this.add = function() { - for (var i = 0, il = arguments.length; i < il; i++) { - list.push(arguments[i]); - } - - if (state) { - exec(); - } - - return this; - }; - - this.execute = function() { - exec(this, arguments); - return this; - }; - }; - - var Server = function(db, name) { - var that = this, - closed = false; - - this.add = function(table) { - if (closed) { - throw 'Database has been closed'; - } - - var records = []; - var counter = 0; - - for (var i = 0; i < arguments.length - 1; i++) { - if (Array.isArray(arguments[i + 1])) { - for (var j = 0; j < arguments[i + 1].length; j++) { - records[counter] = arguments[i + 1][j]; - counter++; - } - } else { - records[counter] = arguments[i + 1]; - counter++; - } - } - - var transaction = db.transaction(table, transactionModes.readwrite), - store = transaction.objectStore(table); - - return new Promise(function(resolve, reject) { - records.forEach(function(record) { - var req; - if (record.item && record.key) { - var key = record.key; - record = record.item; - req = store.add(record, key); - } else { - req = store.add(record); - } - - req.onsuccess = function(e) { - var target = e.target; - var keyPath = target.source.keyPath; - if (keyPath === null) { - keyPath = '__id__'; - } - Object.defineProperty(record, keyPath, { - value: target.result, - enumerable: true, - }); - }; - }); - - transaction.oncomplete = function() { - resolve(records, that); - }; - transaction.onerror = function(e) { - reject(e); - }; - transaction.onabort = function(e) { - reject(e); - }; - }); - }; - - this.update = function(table) { - if (closed) { - throw 'Database has been closed'; - } - - var records = []; - for (var i = 0; i < arguments.length - 1; i++) { - records[i] = arguments[i + 1]; - } - - var transaction = db.transaction(table, transactionModes.readwrite), - store = transaction.objectStore(table), - keyPath = store.keyPath; - - return new Promise(function(resolve, reject) { - records.forEach(function(record) { - var req; - var count; - if (record.item && record.key) { - var key = record.key; - record = record.item; - req = store.put(record, key); - } else { - req = store.put(record); - } - - req.onsuccess = function(e) { - // deferred.notify(); es6 promise can't notify - }; - }); - - transaction.oncomplete = function() { - resolve(records, that); - }; - transaction.onerror = function(e) { - reject(e); - }; - transaction.onabort = function(e) { - reject(e); - }; - }); - }; - - this.remove = function(table, key) { - if (closed) { - throw 'Database has been closed'; - } - var transaction = db.transaction(table, transactionModes.readwrite), - store = transaction.objectStore(table); - - return new Promise(function(resolve, reject) { - var req = store['delete'](key); - transaction.oncomplete = function() { - resolve(key); - }; - transaction.onerror = function(e) { - reject(e); - }; - }); - }; - - this.clear = function(table) { - if (closed) { - throw 'Database has been closed'; - } - var transaction = db.transaction(table, transactionModes.readwrite), - store = transaction.objectStore(table); - - var req = store.clear(); - return new Promise(function(resolve, reject) { - transaction.oncomplete = function() { - resolve(); - }; - transaction.onerror = function(e) { - reject(e); - }; - }); - }; - - this.close = function() { - if (closed) { - throw 'Database has been closed'; - } - db.close(); - closed = true; - delete dbCache[name]; - }; - - this.get = function(table, id) { - if (closed) { - throw 'Database has been closed'; - } - var transaction = db.transaction(table), - store = transaction.objectStore(table); - - var req = store.get(id); - return new Promise(function(resolve, reject) { - req.onsuccess = function(e) { - resolve(e.target.result); - }; - transaction.onerror = function(e) { - reject(e); - }; - }); - }; - - this.query = function(table, index) { - if (closed) { - throw 'Database has been closed'; - } - return new IndexQuery(table, db, index); - }; - - for (var i = 0, il = db.objectStoreNames.length; i < il; i++) { - (function(storeName) { - that[storeName] = {}; - for (var i in that) { - if (!hasOwn.call(that, i) || i === 'close') { - continue; - } - that[storeName][i] = (function(i) { - return function() { - var args = [storeName].concat( - [].slice.call(arguments, 0) - ); - return that[i].apply(that, args); - }; - })(i); - } - })(db.objectStoreNames[i]); - } - }; - - var IndexQuery = function(table, db, indexName) { - var that = this; - var modifyObj = false; - - var runQuery = function( - type, - args, - cursorType, - direction, - limitRange, - filters, - mapper - ) { - var transaction = db.transaction( - table, - modifyObj - ? transactionModes.readwrite - : transactionModes.readonly - ), - store = transaction.objectStore(table), - index = indexName ? store.index(indexName) : store, - keyRange = type ? IDBKeyRange[type].apply(null, args) : null, - results = [], - indexArgs = [keyRange], - limitRange = limitRange ? limitRange : null, - filters = filters ? filters : [], - counter = 0; - - if (cursorType !== 'count') { - indexArgs.push(direction || 'next'); - } - - // create a function that will set in the modifyObj properties into - // the passed record. - var modifyKeys = modifyObj ? Object.keys(modifyObj) : false; - var modifyRecord = function(record) { - for (var i = 0; i < modifyKeys.length; i++) { - var key = modifyKeys[i]; - var val = modifyObj[key]; - if (val instanceof Function) val = val(record); - record[key] = val; - } - return record; - }; - - index[cursorType].apply(index, indexArgs).onsuccess = function(e) { - var cursor = e.target.result; - if (typeof cursor === typeof 0) { - results = cursor; - } else if (cursor) { - if (limitRange !== null && limitRange[0] > counter) { - counter = limitRange[0]; - cursor.advance(limitRange[0]); - } else if ( - limitRange !== null && - counter >= limitRange[0] + limitRange[1] - ) { - //out of limit range... skip - } else { - var matchFilter = true; - var result = - 'value' in cursor ? cursor.value : cursor.key; - - filters.forEach(function(filter) { - if (!filter || !filter.length) { - //Invalid filter do nothing - } else if (filter.length === 2) { - matchFilter = - matchFilter && - result[filter[0]] === filter[1]; - } else { - matchFilter = - matchFilter && - filter[0].apply(undefined, [result]); - } - }); - - if (matchFilter) { - counter++; - results.push(mapper(result)); - // if we're doing a modify, run it now - if (modifyObj) { - result = modifyRecord(result); - cursor.update(result); - } - } - cursor['continue'](); - } - } - }; - - return new Promise(function(resolve, reject) { - transaction.oncomplete = function() { - resolve(results); - }; - transaction.onerror = function(e) { - reject(e); - }; - transaction.onabort = function(e) { - reject(e); - }; - }); - }; - - var Query = function(type, args) { - var direction = 'next', - cursorType = 'openCursor', - filters = [], - limitRange = null, - mapper = defaultMapper, - unique = false; - - var execute = function() { - return runQuery( - type, - args, - cursorType, - unique ? direction + 'unique' : direction, - limitRange, - filters, - mapper - ); - }; - - var limit = function() { - limitRange = Array.prototype.slice.call(arguments, 0, 2); - if (limitRange.length == 1) { - limitRange.unshift(0); - } - - return { - execute: execute, - }; - }; - var count = function() { - direction = null; - cursorType = 'count'; - - return { - execute: execute, - }; - }; - var keys = function() { - cursorType = 'openKeyCursor'; - - return { - desc: desc, - execute: execute, - filter: filter, - distinct: distinct, - map: map, - }; - }; - var filter = function() { - filters.push(Array.prototype.slice.call(arguments, 0, 2)); - - return { - keys: keys, - execute: execute, - filter: filter, - desc: desc, - distinct: distinct, - modify: modify, - limit: limit, - map: map, - }; - }; - var desc = function() { - direction = 'prev'; - - return { - keys: keys, - execute: execute, - filter: filter, - distinct: distinct, - modify: modify, - map: map, - }; - }; - var distinct = function() { - unique = true; - return { - keys: keys, - count: count, - execute: execute, - filter: filter, - desc: desc, - modify: modify, - map: map, - }; - }; - var modify = function(update) { - modifyObj = update; - return { - execute: execute, - }; - }; - var map = function(fn) { - mapper = fn; - - return { - execute: execute, - count: count, - keys: keys, - filter: filter, - desc: desc, - distinct: distinct, - modify: modify, - limit: limit, - map: map, - }; - }; - - return { - execute: execute, - count: count, - keys: keys, - filter: filter, - desc: desc, - distinct: distinct, - modify: modify, - limit: limit, - map: map, - }; - }; - - 'only bound upperBound lowerBound'.split(' ').forEach(function(name) { - that[name] = function() { - return new Query(name, arguments); - }; - }); - - this.filter = function() { - var query = new Query(null, null); - return query.filter.apply(query, arguments); - }; - - this.all = function() { - return this.filter(); - }; - }; - - var createSchema = function(e, schema, db) { - if (typeof schema === 'function') { - schema = schema(); - } - - for (var tableName in schema) { - var table = schema[tableName]; - var store; - if ( - !hasOwn.call(schema, tableName) || - db.objectStoreNames.contains(tableName) - ) { - store = e.currentTarget.transaction.objectStore(tableName); - } else { - store = db.createObjectStore(tableName, table.key); - } - - for (var indexKey in table.indexes) { - var index = table.indexes[indexKey]; - try { - store.index(indexKey); - } catch (e) { - store.createIndex( - indexKey, - index.key || indexKey, - Object.keys(index).length ? index : { unique: false } - ); - } - } - } - }; - - var open = function(e, server, version, schema) { - var db = e.target.result; - var s = new Server(db, server); - var upgrade; - - dbCache[server] = db; - - return Promise.resolve(s); - }; - - var dbCache = {}; - - var db = { - version: '0.9.2', - open: function(options) { - var request; - - return new Promise(function(resolve, reject) { - if (dbCache[options.server]) { - open( - { - target: { - result: dbCache[options.server], - }, - }, - options.server, - options.version, - options.schema - ).then(resolve, reject); - } else { - request = getIndexedDB().open( - options.server, - options.version - ); - - request.onsuccess = function(e) { - open( - e, - options.server, - options.version, - options.schema - ).then(resolve, reject); - }; - - request.onupgradeneeded = function(e) { - createSchema(e, options.schema, e.target.result); - }; - request.onerror = function(e) { - reject(e); - }; - } - }); - }, - }; - - if ( - typeof module !== 'undefined' && - typeof module.exports !== 'undefined' - ) { - module.exports = db; - } else if (typeof define === 'function' && define.amd) { - define(function() { - return db; - }); - } else { - window.db = db; - } -})(window); diff --git a/js/dbService.js b/js/dbService.js deleted file mode 100644 index cbfb1ed..0000000 --- a/js/dbService.js +++ /dev/null @@ -1,173 +0,0 @@ -/* global db */ -// eslint-disable-next-line no-var -var dbService = { - DB_SERVER: 'spaces', - DB_VERSION: '1', - DB_SESSIONS: 'ttSessions', - - noop() {}, - - /** - * INDEXEDDB FUNCTIONS - */ - getDb() { - return db.open({ - server: dbService.DB_SERVER, - version: dbService.DB_VERSION, - schema: dbService.getSchema, - }); - }, - - /** - * Properties of a session object - * session.id: auto-generated indexedDb object id - * session.sessionHash: a hash formed from the combined urls in the session window - * session.name: the saved name of the session - * session.tabs: an array of chrome tab objects (often taken from the chrome window obj) - * session.history: an array of chrome tab objects that have been removed from the session - * session.lastAccess: timestamp that gets updated with every window focus - */ - getSchema() { - return { - ttSessions: { - key: { - keyPath: 'id', - autoIncrement: true, - }, - indexes: { - id: {}, - }, - }, - }; - }, - - _fetchAllSessions() { - return dbService.getDb().then(s => { - return s - .query(dbService.DB_SESSIONS) - .all() - .execute(); - }); - }, - - _fetchSessionById: id => { - const _id = typeof id === 'string' ? parseInt(id, 10) : id; - return dbService.getDb().then(s => { - return s - .query(dbService.DB_SESSIONS, 'id') - .only(_id) - .distinct() - .desc() - .execute() - .then(results => { - return results.length > 0 ? results[0] : null; - }); - }); - }, - - fetchAllSessions: callback => { - const _callback = - typeof callback !== 'function' ? dbService.noop : callback; - dbService._fetchAllSessions().then(sessions => { - _callback(sessions); - }); - }, - - fetchSessionById: (id, callback) => { - const _id = typeof id === 'string' ? parseInt(id, 10) : id; - const _callback = - typeof callback !== 'function' ? dbService.noop : callback; - dbService._fetchSessionById(_id).then(session => { - _callback(session); - }); - }, - - fetchSessionNames: callback => { - const _callback = - typeof callback !== 'function' ? dbService.noop : callback; - - dbService._fetchAllSessions().then(sessions => { - _callback( - sessions.map(session => { - return session.name; - }) - ); - }); - }, - - fetchSessionByName: (sessionName, callback) => { - const _callback = - typeof callback !== 'function' ? dbService.noop : callback; - - dbService._fetchAllSessions().then(sessions => { - let matchIndex; - const matchFound = sessions.some((session, index) => { - if (session.name.toLowerCase() === sessionName.toLowerCase()) { - matchIndex = index; - return true; - } - return false; - }); - - if (matchFound) { - _callback(sessions[matchIndex]); - } else { - _callback(false); - } - }); - }, - - createSession: (session, callback) => { - const _callback = - typeof callback !== 'function' ? dbService.noop : callback; - - // delete session id in case it already exists - const { id, ..._session } = session; - - dbService - .getDb() - .then(s => { - return s.add(dbService.DB_SESSIONS, _session); - }) - .then(result => { - if (result.length > 0) { - _callback(result[0]); - } - }); - }, - - updateSession: (session, callback) => { - const _callback = - typeof callback !== 'function' ? dbService.noop : callback; - - // ensure session id is set - if (!session.id) { - _callback(false); - return; - } - - dbService - .getDb() - .then(s => { - return s.update(dbService.DB_SESSIONS, session); - }) - .then(result => { - if (result.length > 0) { - _callback(result[0]); - } - }); - }, - - removeSession: (id, callback) => { - const _id = typeof id === 'string' ? parseInt(id, 10) : id; - const _callback = - typeof callback !== 'function' ? dbService.noop : callback; - - dbService - .getDb() - .then(s => { - return s.remove(dbService.DB_SESSIONS, _id); - }) - .then(_callback); - }, -}; diff --git a/js/popup.js b/js/popup.js index 8781ddd..bd8f5ec 100644 --- a/js/popup.js +++ b/js/popup.js @@ -1,46 +1,50 @@ /* global chrome spacesRenderer */ -(() => { - const UNSAVED_SESSION = '(unnamed window)'; - const NO_HOTKEY = 'no hotkey set'; - - const nodes = {}; - let globalCurrentSpace; - let globalTabId; - let globalUrl; - let globalWindowId; - let globalSessionName; - - /* - * POPUP INIT - */ - +import { getHashVariable } from './common.js'; +import { spacesRenderer } from './spacesRenderer.js'; +import { checkSessionOverwrite, escapeHtml } from './utils.js'; + +const UNSAVED_SESSION = '(unnamed window)'; +const NO_HOTKEY = 'no hotkey set'; + +/** + * Handles popup menu clicks by generating popup params and reloading + * @param {string} action The popup action ('switch' or 'move') + */ +export async function handlePopupMenuClick(action) { + const params = await chrome.runtime.sendMessage({'action': 'generatePopupParams', 'popupAction': action}); + if (!params) return; + window.location.hash = params; + window.location.reload(); +} + +const nodes = {}; +let globalCurrentSpace; +let globalTabId; +let globalUrl; +let globalWindowId; +let globalSessionName; + +/** Initialize the popup window. */ +function initializePopup() { document.addEventListener('DOMContentLoaded', async () => { - const { utils, spaces } = chrome.extension.getBackgroundPage(); - const url = utils.getHashVariable('url', window.location.href); + const url = getHashVariable('url', window.location.href); globalUrl = url !== '' ? decodeURIComponent(url) : false; - const windowId = utils.getHashVariable( - 'windowId', - window.location.href - ); + const currentWindow = await chrome.windows.getCurrent({ populate: true }); + const windowId = currentWindow.id; globalWindowId = windowId !== '' ? windowId : false; - globalTabId = utils.getHashVariable('tabId', window.location.href); - const sessionName = utils.getHashVariable( + globalTabId = getHashVariable('tabId', window.location.href); + const sessionName = getHashVariable( 'sessionName', window.location.href ); globalSessionName = sessionName && sessionName !== 'false' ? sessionName : false; - const action = utils.getHashVariable('action', window.location.href); + const action = getHashVariable('action', window.location.href); const requestSpacePromise = globalWindowId - ? new Promise(resolve => - spaces.requestSpaceFromWindowId( - parseInt(globalWindowId, 10), - resolve - ) - ) - : new Promise(resolve => spaces.requestCurrentSpace(resolve)); + ? chrome.runtime.sendMessage({ action: 'requestSpaceFromWindowId', windowId: globalWindowId }) + : chrome.runtime.sendMessage({ action: 'requestCurrentSpace' }); requestSpacePromise.then(space => { globalCurrentSpace = space; @@ -48,387 +52,396 @@ routeView(action); }); }); - - function routeView(action) { - if (action === 'move') { - renderMoveCard(); - } else if (action === 'switch') { - renderSwitchCard(); - } else { - renderMainCard(); - } +} + +// Auto-initialize when loaded in browser context +if (typeof document !== 'undefined' && typeof window !== 'undefined') { + initializePopup(); +} + +function routeView(action) { + if (action === 'move') { + renderMoveCard(); + } else if (action === 'switch') { + renderSwitchCard(); + } else { + renderMainCard(); } - - /* - * COMMON - */ - - function renderCommon() { - document.getElementById( - 'activeSpaceTitle' - ).value = globalCurrentSpace.name - ? globalCurrentSpace.name - : UNSAVED_SESSION; - - document.querySelector('body').onkeyup = e => { - // listen for escape key - if (e.keyCode === 27) { - handleCloseAction(); - // } else if (e.keyCode === 13) { - // handleNameSave(); - } - }; - document.getElementById('spaceEdit').addEventListener('click', () => { +} + +/** + * COMMON + */ + +function renderCommon() { + document.getElementById( + 'activeSpaceTitle' + ).value = globalCurrentSpace.name + ? globalCurrentSpace.name + : UNSAVED_SESSION; + + document.querySelector('body').onkeyup = e => { + // listen for escape key + if (e.keyCode === 27) { + handleCloseAction(); + // } else if (e.keyCode === 13) { + // handleNameSave(); + } + }; + document.getElementById('spaceEdit').addEventListener('click', () => { + handleNameEdit(); + }); + document + .getElementById('activeSpaceTitle') + .addEventListener('focus', () => { handleNameEdit(); }); - document - .getElementById('activeSpaceTitle') - .addEventListener('focus', () => { - handleNameEdit(); - }); - document.getElementById('activeSpaceTitle').onkeyup = e => { - // listen for enter key - if (e.keyCode === 13) { - document.getElementById('activeSpaceTitle').blur(); - } - }; - document - .getElementById('activeSpaceTitle') - .addEventListener('blur', () => { - handleNameSave(); - }); - } + document.getElementById('activeSpaceTitle').onkeyup = e => { + // listen for enter key + if (e.keyCode === 13) { + document.getElementById('activeSpaceTitle').blur(); + } + }; + document + .getElementById('activeSpaceTitle') + .addEventListener('blur', () => { + handleNameSave(); + }); +} - function handleCloseAction() { - const { utils } = chrome.extension.getBackgroundPage(); - const opener = utils.getHashVariable('opener', window.location.href); - if (opener && opener === 'bg') { +function handleCloseAction() { + const opener = getHashVariable('opener', window.location.href); + if (opener && opener === 'bg') { + chrome.runtime.sendMessage({ + action: 'requestClose', + }); + } else { + window.close(); + } +} + +/** + * MAIN POPUP VIEW + */ + +async function renderMainCard() { + const hotkeys = await requestHotkeys(); + document.querySelector( + '#switcherLink .hotkey' + ).innerHTML = hotkeys.switchCode ? hotkeys.switchCode : NO_HOTKEY; + document.querySelector( + '#moverLink .hotkey' + ).innerHTML = hotkeys.moveCode ? hotkeys.moveCode : NO_HOTKEY; + + const hotkeyEls = document.querySelectorAll('.hotkey'); + for (let i = 0; i < hotkeyEls.length; i += 1) { + hotkeyEls[i].addEventListener('click', () => { chrome.runtime.sendMessage({ - action: 'requestClose', + action: 'requestShowKeyboardShortcuts', }); - } else { window.close(); - } - } - - /* - * MAIN POPUP VIEW - */ - - function renderMainCard() { - const { spaces } = chrome.extension.getBackgroundPage(); - spaces.requestHotkeys(hotkeys => { - document.querySelector( - '#switcherLink .hotkey' - ).innerHTML = hotkeys.switchCode ? hotkeys.switchCode : NO_HOTKEY; - document.querySelector( - '#moverLink .hotkey' - ).innerHTML = hotkeys.moveCode ? hotkeys.moveCode : NO_HOTKEY; }); + } - const hotkeyEls = document.querySelectorAll('.hotkey'); - for (let i = 0; i < hotkeyEls.length; i += 1) { - hotkeyEls[i].addEventListener('click', () => { - chrome.runtime.sendMessage({ - action: 'requestShowKeyboardShortcuts', - }); - window.close(); + document + .querySelector('#allSpacesLink .optionText') + .addEventListener('click', () => { + chrome.runtime.sendMessage({ + action: 'requestShowSpaces', }); + window.close(); + }); + document + .querySelector('#switcherLink .optionText') + .addEventListener('click', () => handlePopupMenuClick('switch')); + document + .querySelector('#moverLink .optionText') + .addEventListener('click', () => handlePopupMenuClick('move')); +} + +async function requestHotkeys() { + const commands = await chrome.commands.getAll(); + let switchStr; + let moveStr; + let spacesStr; + + commands.forEach(command => { + if (command.name === 'spaces-switch') { + switchStr = command.shortcut; + } else if (command.name === 'spaces-move') { + moveStr = command.shortcut; + } else if (command.name === 'spaces-open') { + spacesStr = command.shortcut; } + }); - document - .querySelector('#allSpacesLink .optionText') - .addEventListener('click', () => { - chrome.runtime.sendMessage({ - action: 'requestShowSpaces', - }); - window.close(); - }); - document - .querySelector('#switcherLink .optionText') - .addEventListener('click', () => { - spaces.generatePopupParams('switch').then(params => { - if (!params) return; - window.location.hash = params; - window.location.reload(); - }); - // renderSwitchCard() - }); - document - .querySelector('#moverLink .optionText') - .addEventListener('click', () => { - spaces.generatePopupParams('move').then(params => { - if (!params) return; - window.location.hash = params; - window.location.reload(); - }); - // renderMoveCard() - }); - } - - function handleNameEdit() { - const inputEl = document.getElementById('activeSpaceTitle'); - inputEl.focus(); - if (inputEl.value === UNSAVED_SESSION) { - inputEl.value = ''; - } + return { + switchCode: switchStr, + moveCode: moveStr, + spacesCode: spacesStr, + }; +} + +function handleNameEdit() { + const inputEl = document.getElementById('activeSpaceTitle'); + inputEl.focus(); + if (inputEl.value === UNSAVED_SESSION) { + inputEl.value = ''; } +} - function handleNameSave() { - const inputEl = document.getElementById('activeSpaceTitle'); - const newName = inputEl.value; - - if ( - newName === UNSAVED_SESSION || - newName === globalCurrentSpace.name - ) { - return; - } +async function handleNameSave() { + const inputEl = document.getElementById('activeSpaceTitle'); + const newName = inputEl.value; - if (globalCurrentSpace.sessionId) { - chrome.runtime.sendMessage( - { - action: 'updateSessionName', - sessionName: newName, - sessionId: globalCurrentSpace.sessionId, - }, - () => {} - ); - } else { - chrome.runtime.sendMessage( - { - action: 'saveNewSession', - sessionName: newName, - windowId: globalCurrentSpace.windowId, - }, - () => {} - ); - } + if ( + newName === UNSAVED_SESSION || + newName === globalCurrentSpace.name + ) { + return; } - /* - * SWITCHER VIEW - */ - - function renderSwitchCard() { - document.getElementById( - 'popupContainer' - ).innerHTML = document.getElementById('switcherTemplate').innerHTML; - chrome.runtime.sendMessage({ action: 'requestAllSpaces' }, spaces => { - spacesRenderer.initialise(8, true); - spacesRenderer.renderSpaces(spaces); - - document.getElementById('spaceSelectForm').onsubmit = e => { - e.preventDefault(); - handleSwitchAction(getSelectedSpace()); - }; - - const allSpaceEls = document.querySelectorAll('.space'); - Array.prototype.forEach.call(allSpaceEls, el => { - // eslint-disable-next-line no-param-reassign - el.onclick = () => { - handleSwitchAction(el); - }; - }); - }); - } - - function getSelectedSpace() { - return document.querySelector('.space.selected'); + const canOverwrite = await checkSessionOverwrite(newName); + if (!canOverwrite) { + inputEl.value = globalCurrentSpace.name || UNSAVED_SESSION; + inputEl.blur(); + return; } - function handleSwitchAction(selectedSpaceEl) { + if (globalCurrentSpace.sessionId) { chrome.runtime.sendMessage({ - action: 'switchToSpace', - sessionId: selectedSpaceEl.getAttribute('data-sessionId'), - windowId: selectedSpaceEl.getAttribute('data-windowId'), + action: 'updateSessionName', + deleteOld: true, + sessionName: newName, + sessionId: globalCurrentSpace.sessionId, + }); + } else { + chrome.runtime.sendMessage({ + action: 'saveNewSession', + deleteOld: true, + sessionName: newName, + windowId: globalCurrentSpace.windowId, }); - window.close(); } +} + +/** + * SWITCHER VIEW + */ + +async function renderSwitchCard() { + document.getElementById( + 'popupContainer' + ).innerHTML = document.getElementById('switcherTemplate').innerHTML; + + const spaces = await chrome.runtime.sendMessage({ action: 'requestAllSpaces' }); + spacesRenderer.initialise(8, true); + spacesRenderer.renderSpaces(spaces); + + document.getElementById('spaceSelectForm').onsubmit = e => { + e.preventDefault(); + handleSwitchAction(getSelectedSpace()); + }; + + const allSpaceEls = document.querySelectorAll('.space'); + Array.prototype.forEach.call(allSpaceEls, el => { + // eslint-disable-next-line no-param-reassign + el.onclick = () => { + handleSwitchAction(el); + }; + }); +} + +function getSelectedSpace() { + return document.querySelector('.space.selected'); +} - /* - * MOVE VIEW - */ - - function renderMoveCard() { - document.getElementById( - 'popupContainer' - ).innerHTML = document.getElementById('moveTemplate').innerHTML; - - // initialise global handles to key elements (singletons) - // nodes.home = document.getElementById('spacesHome'); - nodes.body = document.querySelector('body'); - nodes.spaceEditButton = document.getElementById('spaceEdit'); - nodes.moveForm = document.getElementById('spaceSelectForm'); - nodes.moveInput = document.getElementById('sessionsInput'); - nodes.activeSpaceTitle = document.getElementById('activeSpaceTitle'); - nodes.activeTabTitle = document.getElementById('activeTabTitle'); - nodes.activeTabFavicon = document.getElementById('activeTabFavicon'); - nodes.okButton = document.getElementById('moveBtn'); - nodes.cancelButton = document.getElementById('cancelBtn'); - - // nodes.home.setAttribute('href', chrome.extension.getURL('spaces.html')); - - nodes.moveForm.onsubmit = e => { - e.preventDefault(); +async function handleSwitchAction(selectedSpaceEl) { + await chrome.runtime.sendMessage({ + action: 'switchToSpace', + sessionId: selectedSpaceEl.getAttribute('data-sessionId'), + windowId: selectedSpaceEl.getAttribute('data-windowId'), + }); + // Wait for the response from the background message handler before + // closing the window. + window.close(); +} + +/** + * MOVE VIEW + */ + +async function renderMoveCard() { + document.getElementById( + 'popupContainer' + ).innerHTML = document.getElementById('moveTemplate').innerHTML; + + // initialise global handles to key elements (singletons) + // nodes.home = document.getElementById('spacesHome'); + nodes.body = document.querySelector('body'); + nodes.spaceEditButton = document.getElementById('spaceEdit'); + nodes.moveForm = document.getElementById('spaceSelectForm'); + nodes.moveInput = document.getElementById('sessionsInput'); + nodes.activeSpaceTitle = document.getElementById('activeSpaceTitle'); + nodes.activeTabTitle = document.getElementById('activeTabTitle'); + nodes.activeTabFavicon = document.getElementById('activeTabFavicon'); + nodes.okButton = document.getElementById('moveBtn'); + nodes.cancelButton = document.getElementById('cancelBtn'); + + // nodes.home.setAttribute('href', chrome.extension.getURL('spaces.html')); + + nodes.moveForm.onsubmit = e => { + e.preventDefault(); + handleSelectAction(); + }; + + nodes.body.onkeyup = e => { + // highlight ok button when you start typing + if (nodes.moveInput.value.length > 0) { + nodes.okButton.className = 'button okBtn selected'; + } else { + nodes.okButton.className = 'button okBtn'; + } + + // listen for escape key + if (e.keyCode === 27) { + handleCloseAction(); + } + }; + + nodes.spaceEditButton.onclick = () => { + handleEditSpace(); + }; + nodes.okButton.onclick = () => { + handleSelectAction(); + }; + nodes.cancelButton.onclick = () => { + handleCloseAction(); + }; + + // update currentSpaceDiv + // nodes.windowTitle.innerHTML = "Current space: " + (globalSessionName ? globalSessionName : 'unnamed'); + nodes.activeSpaceTitle.innerHTML = escapeHtml(globalSessionName) || '(unnamed)'; + // selectSpace(nodes.activeSpace); + + await updateTabDetails(); + + const spaces = await chrome.runtime.sendMessage({ action: 'requestAllSpaces' }); + // remove currently visible space + const filteredSpaces = spaces.filter(space => { + return `${space.windowId}` !== globalWindowId; + }); + spacesRenderer.initialise(5, false); + spacesRenderer.renderSpaces(filteredSpaces); + + const allSpaceEls = document.querySelectorAll('.space'); + for (const el of allSpaceEls) { + // eslint-disable-next-line no-param-reassign + const existingClickHandler = el.onclick; + el.onclick = e => { + existingClickHandler(e); handleSelectAction(); }; + } +} + +async function updateTabDetails() { + let faviconSrc; - nodes.body.onkeyup = e => { - // highlight ok button when you start typing - if (nodes.moveInput.value.length > 0) { - nodes.okButton.className = 'button okBtn selected'; + // if we are working with an open chrome tab + if (globalTabId.length > 0) { + const tab = await chrome.runtime.sendMessage({ + action: 'requestTabDetail', + tabId: globalTabId, + }); + + if (tab) { + nodes.activeTabTitle.innerHTML = escapeHtml(tab.title); + + // try to get best favicon url path + if ( + tab.favIconUrl && + tab.favIconUrl.indexOf('chrome://theme') < 0 + ) { + faviconSrc = tab.favIconUrl; } else { - nodes.okButton.className = 'button okBtn'; + // TODO(codedread): Fix this, it errors. + // faviconSrc = `chrome://favicon/${tab.url}`; } + nodes.activeTabFavicon.setAttribute('src', faviconSrc); - // listen for escape key - if (e.keyCode === 27) { - handleCloseAction(); - } - }; + nodes.moveInput.setAttribute( + 'placeholder', + 'Move tab to..' + ); - nodes.spaceEditButton.onclick = () => { - handleEditSpace(); - }; - nodes.okButton.onclick = () => { - handleSelectAction(); - }; - nodes.cancelButton.onclick = () => { - handleCloseAction(); - }; + // nodes.windowTitle.innerHTML = tab.title; + // nodes.windowFavicon.setAttribute('href', faviconSrc); + } - // update currentSpaceDiv - // nodes.windowTitle.innerHTML = "Current space: " + (globalSessionName ? globalSessionName : 'unnamed'); - nodes.activeSpaceTitle.innerHTML = globalSessionName || '(unnamed)'; - // selectSpace(nodes.activeSpace); - - updateTabDetails(); - - chrome.runtime.sendMessage( - { - action: 'requestAllSpaces', - }, - spaces => { - // remove currently visible space - const filteredSpaces = spaces.filter(space => { - return `${space.windowId}` !== globalWindowId; - }); - spacesRenderer.initialise(5, false); - spacesRenderer.renderSpaces(filteredSpaces); - - const allSpaceEls = document.querySelectorAll('.space'); - Array.prototype.forEach.call(allSpaceEls, el => { - // eslint-disable-next-line no-param-reassign - const existingClickHandler = el.onclick; - el.onclick = e => { - existingClickHandler(e); - handleSelectAction(); - }; - }); - } - ); + // else if we are dealing with a url only + } else if (globalUrl) { + const cleanUrl = + globalUrl.indexOf('://') > 0 + ? globalUrl.substr( + globalUrl.indexOf('://') + 3, + globalUrl.length + ) + : globalUrl; + nodes.activeTabTitle.innerHTML = escapeHtml(cleanUrl); + nodes.activeTabFavicon.setAttribute('src', '/img/new.png'); + + nodes.moveInput.setAttribute('placeholder', 'Add tab to..'); } +} - function updateTabDetails() { - let faviconSrc; - - // if we are working with an open chrome tab - if (globalTabId.length > 0) { - chrome.runtime.sendMessage( - { - action: 'requestTabDetail', - tabId: globalTabId, - }, - tab => { - if (tab) { - nodes.activeTabTitle.innerHTML = tab.title; - - // try to get best favicon url path - if ( - tab.favIconUrl && - tab.favIconUrl.indexOf('chrome://theme') < 0 - ) { - faviconSrc = tab.favIconUrl; - } else { - faviconSrc = `chrome://favicon/${tab.url}`; - } - nodes.activeTabFavicon.setAttribute('src', faviconSrc); - - nodes.moveInput.setAttribute( - 'placeholder', - 'Move tab to..' - ); - - // nodes.windowTitle.innerHTML = tab.title; - // nodes.windowFavicon.setAttribute('href', faviconSrc); - } - } - ); +function handleSelectAction() { + const selectedSpaceEl = document.querySelector('.space.selected'); + const sessionId = selectedSpaceEl.getAttribute('data-sessionId'); + const windowId = selectedSpaceEl.getAttribute('data-windowId'); + const newSessionName = nodes.moveInput.value; + const params = {}; + + if (sessionId && sessionId !== 'false') { + params.sessionId = sessionId; - // else if we are dealing with a url only + if (globalTabId) { + params.action = 'moveTabToSession'; + params.tabId = globalTabId; } else if (globalUrl) { - const cleanUrl = - globalUrl.indexOf('://') > 0 - ? globalUrl.substr( - globalUrl.indexOf('://') + 3, - globalUrl.length - ) - : globalUrl; - nodes.activeTabTitle.innerHTML = cleanUrl; - nodes.activeTabFavicon.setAttribute('src', '/img/new.png'); - - nodes.moveInput.setAttribute('placeholder', 'Add tab to..'); + params.action = 'addLinkToSession'; + params.url = globalUrl; } - } + } else if (windowId && windowId !== 'false') { + params.windowId = windowId; - function handleSelectAction() { - const selectedSpaceEl = document.querySelector('.space.selected'); - const sessionId = selectedSpaceEl.getAttribute('data-sessionId'); - const windowId = selectedSpaceEl.getAttribute('data-windowId'); - const newSessionName = nodes.moveInput.value; - const params = {}; - - if (sessionId && sessionId !== 'false') { - params.sessionId = sessionId; - - if (globalTabId) { - params.action = 'moveTabToSession'; - params.tabId = globalTabId; - } else if (globalUrl) { - params.action = 'addLinkToSession'; - params.url = globalUrl; - } - } else if (windowId && windowId !== 'false') { - params.windowId = windowId; - - if (globalTabId) { - params.action = 'moveTabToWindow'; - params.tabId = globalTabId; - } else if (globalUrl) { - params.action = 'addLinkToWindow'; - params.url = globalUrl; - } - } else { - params.sessionName = newSessionName; - - if (globalTabId) { - params.action = 'moveTabToNewSession'; - params.tabId = globalTabId; - } else if (globalUrl) { - params.action = 'addLinkToNewSession'; - params.url = globalUrl; - } + if (globalTabId) { + params.action = 'moveTabToWindow'; + params.tabId = globalTabId; + } else if (globalUrl) { + params.action = 'addLinkToWindow'; + params.url = globalUrl; } + } else { + params.sessionName = newSessionName; - chrome.runtime.sendMessage(params); - // this window will be closed by background script - } - function handleEditSpace() { - chrome.runtime.sendMessage({ - action: 'requestShowSpaces', - windowId: globalWindowId, - edit: 'true', - }); + if (globalTabId) { + params.action = 'moveTabToNewSession'; + params.tabId = globalTabId; + } else if (globalUrl) { + params.action = 'addLinkToNewSession'; + params.url = globalUrl; + } } -})(); + + chrome.runtime.sendMessage(params); + // this window will be closed by background script +} + +function handleEditSpace() { + chrome.runtime.sendMessage({ + action: 'requestShowSpaces', + windowId: globalWindowId, + edit: 'true', + }); +} diff --git a/js/spaces.js b/js/spaces.js index c6bb221..4e2f98c 100644 --- a/js/spaces.js +++ b/js/spaces.js @@ -1,730 +1,695 @@ /* global chrome */ -(() => { - const UNSAVED_SESSION = 'Unnamed window'; - const nodes = {}; - let globalSelectedSpace; - let bannerState; - - // METHODS FOR RENDERING SIDENAV (spaces list) - - function renderSpacesList(spaces) { - let spaceEl; - - nodes.openSpaces.innerHTML = ''; - nodes.closedSpaces.innerHTML = ''; - - spaces.forEach(space => { - spaceEl = renderSpaceListEl(space); - if (space.windowId) { - nodes.openSpaces.appendChild(spaceEl); - } else { - nodes.closedSpaces.appendChild(spaceEl); - } - }); - } - - function renderSpaceListEl(space) { - let hash; - - const listEl = document.createElement('li'); - const linkEl = document.createElement('a'); - - if (space.sessionId) { - hash = `#sessionId=${space.sessionId}`; - } else if (space.windowId) { - hash = `#windowId=${space.windowId}`; - } - linkEl.setAttribute('href', hash); - - if (space.name) { - linkEl.innerHTML = space.name; +import { getHashVariable } from './common.js'; +import { checkSessionOverwrite, escapeHtml } from './utils.js'; + +const UNSAVED_SESSION_NAME = 'Unnamed window'; +const UNSAVED_SESSION = `${UNSAVED_SESSION_NAME}`; +const nodes = {}; +let globalSelectedSpace; +let bannerState; + +// METHODS FOR RENDERING SIDENAV (spaces list) + +function renderSpacesList(spaces) { + let spaceEl; + + // Clear globalSelectedSpace at the start - it will be set if we find a match + globalSelectedSpace = null; + + nodes.openSpaces.innerHTML = ''; + nodes.closedSpaces.innerHTML = ''; + + spaces.forEach(space => { + spaceEl = renderSpaceListEl(space); + if (space.windowId) { + nodes.openSpaces.appendChild(spaceEl); } else { - linkEl.innerHTML = UNSAVED_SESSION; + nodes.closedSpaces.appendChild(spaceEl); } - - if ( - globalSelectedSpace && - ((space.windowId && - globalSelectedSpace.windowId === space.windowId) || - (space.sessionId && - globalSelectedSpace.sessionId === space.sessionId)) - ) { - linkEl.className = 'selected'; - } - - // if (space && !space.windowId) { - // iconEl.className = 'icon fa fa-external-link'; - // iconEl.setAttribute('title', 'Load this space'); - // } else { - // iconEl.className = 'icon fa fa-arrow-circle-right'; - // iconEl.setAttribute('title', 'Switch to this space'); - // } - // listEl.appendChild(iconEl); - - // //add event listener for each load/switch icon - // iconEl.addEventListener("click", () => { - // handleLoadSpace(space.sessionId, space.windowId); - // }); - - listEl.appendChild(linkEl); - return listEl; - } - - // METHODS FOR RENDERING MAIN CONTENT (space detail) - - function renderSpaceDetail(space, editMode) { - updateNameForm(space); - toggleNameEditMode(editMode); - updateButtons(space); - renderTabs(space); - } - - function updateNameForm(space) { - if (space && space.name) { - nodes.nameFormInput.value = space.name; - nodes.nameFormDisplay.innerHTML = space.name; + }); +} + +function renderSpaceListEl(space) { + let hash; + + const listEl = document.createElement('li'); + const linkEl = document.createElement('a'); + + if (space.sessionId) { + hash = `#sessionId=${space.sessionId}`; + } else if (space.windowId) { + hash = `#windowId=${space.windowId}`; + } + linkEl.setAttribute('href', hash); + + if (space.name) { + linkEl.innerHTML = escapeHtml(space.name); + } else { + linkEl.innerHTML = UNSAVED_SESSION; + } + + // Check if this space should be selected based on current hash + const currentSessionId = getHashVariable('sessionId', window.location.href); + const currentWindowId = getHashVariable('windowId', window.location.href); + + if ( + (currentSessionId && space.sessionId && currentSessionId == space.sessionId) || + (currentWindowId && space.windowId && currentWindowId == space.windowId) + ) { + linkEl.className = 'selected'; + // Also update globalSelectedSpace for the detail view + globalSelectedSpace = space; + } + + // if (space && !space.windowId) { + // iconEl.className = 'icon fa fa-external-link'; + // iconEl.setAttribute('title', 'Load this space'); + // } else { + // iconEl.className = 'icon fa fa-arrow-circle-right'; + // iconEl.setAttribute('title', 'Switch to this space'); + // } + // listEl.appendChild(iconEl); + + // //add event listener for each load/switch icon + // iconEl.addEventListener("click", () => { + // handleLoadSpace(space.sessionId, space.windowId); + // }); + + listEl.appendChild(linkEl); + return listEl; +} + +// METHODS FOR RENDERING MAIN CONTENT (space detail) + +function renderSpaceDetail(space, editMode) { + updateNameForm(space); + toggleNameEditMode(editMode); + updateButtons(space); + renderTabs(space); +} + +function updateNameForm(space) { + if (space && space.name) { + nodes.nameFormInput.value = space.name; + nodes.nameFormDisplay.innerHTML = escapeHtml(space.name); + } else { + nodes.nameFormInput.value = ''; + if (space) { + nodes.nameFormDisplay.innerHTML = UNSAVED_SESSION; } else { - nodes.nameFormInput.value = ''; - if (space) { - nodes.nameFormDisplay.innerHTML = UNSAVED_SESSION; - } else { - nodes.nameFormDisplay.innerHTML = ''; - } + nodes.nameFormDisplay.innerHTML = ''; } } +} + +function toggleNameEditMode(visible) { + if (visible) { + nodes.nameFormDisplay.style.display = 'none'; + nodes.nameFormInput.style.display = 'inline'; + nodes.nameFormInput.focus(); + } else { + nodes.nameFormDisplay.style.display = 'inline'; + nodes.nameFormInput.style.display = 'none'; + } +} + +function updateButtons(space) { + const sessionId = space && space.sessionId ? space.sessionId : false; + const windowId = space && space.windowId ? space.windowId : false; + + nodes.actionSwitch.style.display = windowId ? 'inline-block' : 'none'; + nodes.actionOpen.style.display = + space && !windowId ? 'inline-block' : 'none'; + nodes.actionEdit.style.display = + sessionId || windowId ? 'inline-block' : 'none'; + nodes.actionExport.style.display = + sessionId || windowId ? 'inline-block' : 'none'; + nodes.actionDelete.style.display = + !windowId && sessionId ? 'inline-block' : 'none'; +} + +function renderTabs(space) { + nodes.activeTabs.innerHTML = ''; + nodes.historicalTabs.innerHTML = ''; + + if (!space) { + nodes.spaceDetailContainer.style.display = 'none'; + } else { + nodes.spaceDetailContainer.style.display = 'block'; - function toggleNameEditMode(visible) { - if (visible) { - nodes.nameFormDisplay.style.display = 'none'; - nodes.nameFormInput.style.display = 'inline'; - nodes.nameFormInput.focus(); + space.tabs.forEach(tab => { + nodes.activeTabs.appendChild(renderTabListEl(tab, space)); + }); + if (space.history) { + space.history.forEach(tab => { + nodes.historicalTabs.appendChild( + renderTabListEl(tab, space) + ); + }); } else { - nodes.nameFormDisplay.style.display = 'inline'; - nodes.nameFormInput.style.display = 'none'; + // TODO: hide historical tabs section } } +} - function updateButtons(space) { - const sessionId = space && space.sessionId ? space.sessionId : false; - const windowId = space && space.windowId ? space.windowId : false; - - nodes.actionSwitch.style.display = windowId ? 'inline-block' : 'none'; - nodes.actionOpen.style.display = - space && !windowId ? 'inline-block' : 'none'; - nodes.actionEdit.style.display = - sessionId || windowId ? 'inline-block' : 'none'; - nodes.actionExport.style.display = - sessionId || windowId ? 'inline-block' : 'none'; - nodes.actionDelete.style.display = - !windowId && sessionId ? 'inline-block' : 'none'; - } - - function renderTabs(space) { - nodes.activeTabs.innerHTML = ''; - nodes.historicalTabs.innerHTML = ''; +function renderTabListEl(tab, space) { + let faviconSrc; - if (!space) { - nodes.spaceDetailContainer.style.display = 'none'; - } else { - nodes.spaceDetailContainer.style.display = 'block'; + const listEl = document.createElement('li'); + const linkEl = document.createElement('a'); + const faviconEl = document.createElement('img'); - space.tabs.forEach(tab => { - nodes.activeTabs.appendChild(renderTabListEl(tab, space)); - }); - if (space.history) { - space.history.forEach(tab => { - nodes.historicalTabs.appendChild( - renderTabListEl(tab, space) - ); - }); - } else { - // TODO: hide historical tabs section - } - } + // try to get best favicon url path + if (tab.favIconUrl && tab.favIconUrl.indexOf('chrome://theme') < 0) { + faviconSrc = tab.favIconUrl; + } else { + // TODO(codedread): Fix this, it errors. + //faviconSrc = `chrome://favicon/${tab.url}`; } + faviconEl.setAttribute('src', faviconSrc); - function renderTabListEl(tab, space) { - let faviconSrc; + linkEl.innerHTML = escapeHtml(tab.title ?? tab.url); + linkEl.setAttribute('href', tab.url); + linkEl.setAttribute('target', '_blank'); - const listEl = document.createElement('li'); - const linkEl = document.createElement('a'); - const faviconEl = document.createElement('img'); + // add event listener for each tab link + linkEl.addEventListener('click', e => { + e.preventDefault(); + handleLoadTab(space.sessionId, space.windowId, tab.url); + }); - // try to get best favicon url path - if (tab.favIconUrl && tab.favIconUrl.indexOf('chrome://theme') < 0) { - faviconSrc = tab.favIconUrl; - } else { - faviconSrc = `chrome://favicon/${tab.url}`; - } - faviconEl.setAttribute('src', faviconSrc); + if (tab.duplicate) { + linkEl.className = 'duplicate'; + } - linkEl.innerHTML = tab.title ? tab.title : tab.url; - linkEl.setAttribute('href', tab.url); - linkEl.setAttribute('target', '_blank'); + listEl.appendChild(faviconEl); + listEl.appendChild(linkEl); + return listEl; +} - // add event listener for each tab link - linkEl.addEventListener('click', e => { - e.preventDefault(); - handleLoadTab(space.sessionId, space.windowId, tab.url); - }); +function initialiseBanner(spaces) { + let savedSpacesExist = false; - if (tab.duplicate) { - linkEl.className = 'duplicate'; - } + savedSpacesExist = spaces.some(space => { + if (space.name) return true; + return false; + }); - listEl.appendChild(faviconEl); - listEl.appendChild(linkEl); - return listEl; + if (!savedSpacesExist) { + setBannerState(1); } +} - function initialiseBanner(spaces) { - let savedSpacesExist = false; +async function setBannerState(state) { + const lessonOneEl = document.getElementById('lessonOne'); + const lessonTwoEl = document.getElementById('lessonTwo'); - savedSpacesExist = spaces.some(space => { - if (space.name) return true; - return false; - }); + if (state !== bannerState) { + bannerState = state; - if (!savedSpacesExist) { - setBannerState(1); - } - } - - function setBannerState(state) { - const lessonOneEl = document.getElementById('lessonOne'); - const lessonTwoEl = document.getElementById('lessonTwo'); - - if (state !== bannerState) { - bannerState = state; - - toggleBanner(false, () => { - if (state > 0) { - nodes.banner.style.display = 'block'; - if (state === 1) { - lessonOneEl.style.display = 'block'; - lessonTwoEl.style.display = 'none'; - } else if (state === 2) { - lessonOneEl.style.display = 'none'; - lessonTwoEl.style.display = 'block'; - } - toggleBanner(true); - } - }); + await toggleBanner(false); + if (state > 0) { + nodes.banner.style.display = 'block'; + if (state === 1) { + lessonOneEl.style.display = 'block'; + lessonTwoEl.style.display = 'none'; + } else if (state === 2) { + lessonOneEl.style.display = 'none'; + lessonTwoEl.style.display = 'block'; + } + await toggleBanner(true); } } +} - function toggleBanner(visible, callback) { +async function toggleBanner(visible) { + return new Promise(resolve => { setTimeout(() => { nodes.banner.className = visible ? ' ' : 'hidden'; - if (typeof callback === 'function') { - setTimeout(() => { - callback(); - }, 200); - } + setTimeout(() => resolve(), 200); }, 100); - } - - function toggleModal(visible) { - nodes.modalBlocker.style.display = visible ? 'block' : 'none'; - nodes.modalContainer.style.display = visible ? 'block' : 'none'; - - if (visible) { - nodes.modalInput.value = ''; - nodes.modalInput.focus(); - } - } + }); +} - // ACTION HANDLERS +function toggleModal(visible) { + nodes.modalBlocker.style.display = visible ? 'block' : 'none'; + nodes.modalContainer.style.display = visible ? 'block' : 'none'; - function handleLoadSpace(sessionId, windowId) { - if (sessionId) { - performLoadSession(sessionId, () => { - reroute(sessionId, false, false); - }); - } else if (windowId) { - performLoadWindow(windowId, () => { - reroute(false, windowId, false); - }); - } + if (visible) { + nodes.modalInput.value = ''; + nodes.modalInput.focus(); } +} - function handleLoadTab(sessionId, windowId, tabUrl) { - const noop = () => {}; +// ACTION HANDLERS - if (sessionId) { - performLoadTabInSession(sessionId, tabUrl, noop); - } else if (windowId) { - performLoadTabInWindow(windowId, tabUrl, noop); - } +async function handleLoadSpace(sessionId, windowId) { + if (sessionId) { + await performLoadSession(sessionId); + reroute(sessionId, false, false); + } else if (windowId) { + await performLoadWindow(windowId); + reroute(false, windowId, false); } +} - // if background page requests this page update, then assume we need to do a full page update - function handleAutoUpdateRequest(spaces) { - let matchingSpaces; - let selectedSpace; - - // re-render main spaces list - updateSpacesList(spaces); - - // if we are currently viewing a space detail then update this object from returned spaces list - if (globalSelectedSpace) { - // look for currently selected space by sessionId - if (globalSelectedSpace.sessionId) { - matchingSpaces = spaces.filter(curSpace => { - return curSpace.sessionId === globalSelectedSpace.sessionId; - }); - if (matchingSpaces.length === 1) { - [selectedSpace] = matchingSpaces; - } - - // else look for currently selected space by windowId - } else if (globalSelectedSpace.windowId) { - matchingSpaces = spaces.filter(curSpace => { - return curSpace.windowId === globalSelectedSpace.windowId; - }); - if (matchingSpaces.length === 1) { - [selectedSpace] = matchingSpaces; - } - } - - // update cache and re-render space detail view - if (selectedSpace) { - globalSelectedSpace = selectedSpace; - updateSpaceDetail(true); - } else { - reroute(false, false, true); - } - } +async function handleLoadTab(sessionId, windowId, tabUrl) { + if (sessionId) { + await performLoadTabInSession(sessionId, tabUrl); + } else if (windowId) { + await performLoadTabInWindow(windowId, tabUrl); } +} - function handleNameSave() { - const newName = nodes.nameFormInput.value; - const oldName = globalSelectedSpace.name; - const { sessionId } = globalSelectedSpace; - const { windowId } = globalSelectedSpace; - - // if invalid name set then revert back to non-edit mode - if (newName === oldName || newName.trim() === '') { - updateNameForm(globalSelectedSpace); - toggleNameEditMode(false); - return; - } - - // otherwise call the save service - if (sessionId) { - performSessionUpdate(newName, sessionId, session => { - if (session) reroute(session.id, false, true); - }); - } else if (windowId) { - performNewSessionSave(newName, windowId, session => { - if (session) reroute(session.id, false, true); - }); - } - - // handle banner - if (bannerState === 1) { - setBannerState(2); - } - } +// if background page requests this page update, then assume we need to do a full page update +function handleAutoUpdateRequest(spaces) { + let matchingSpaces; + let selectedSpace; - function handleDelete() { - const { sessionId } = globalSelectedSpace; + // re-render main spaces list + updateSpacesList(spaces); - if (sessionId) { - performDelete(sessionId, () => { - updateSpacesList(); - reroute(false, false, true); + // if we are currently viewing a space detail then update this object from returned spaces list + if (globalSelectedSpace) { + // look for currently selected space by sessionId + if (globalSelectedSpace.sessionId) { + matchingSpaces = spaces.filter(curSpace => { + return curSpace.sessionId === globalSelectedSpace.sessionId; }); - } - } - - // import accepts either a newline separated list of urls or a json backup object - function handleImport() { - let urlList; - let spacesObject; - - const rawInput = nodes.modalInput.value; + if (matchingSpaces.length === 1) { + [selectedSpace] = matchingSpaces; + } - // check for json object - try { - spacesObject = JSON.parse(rawInput); - performRestoreFromBackup(spacesObject, () => { - updateSpacesList(); + // else look for currently selected space by windowId + } else if (globalSelectedSpace.windowId) { + matchingSpaces = spaces.filter(curSpace => { + return curSpace.windowId === globalSelectedSpace.windowId; }); - } catch (e) { - // otherwise treat as a list of newline separated urls - if (rawInput.trim().length > 0) { - urlList = rawInput.split('\n'); - - // filter out bad urls - urlList = urlList.filter(url => { - if (url.trim().length > 0 && url.indexOf('://') > 0) - return true; - return false; - }); - - if (urlList.length > 0) { - performSessionImport(urlList, session => { - if (session) reroute(session.id, false, true); - }); - } + if (matchingSpaces.length === 1) { + [selectedSpace] = matchingSpaces; } } - } - - function handleBackup() { - const leanSpaces = []; - - fetchAllSpaces(spaces => { - // strip out unnessary content from each space - spaces.forEach(space => { - const leanTabs = []; - space.tabs.forEach(curTab => { - leanTabs.push({ - title: curTab.title, - url: normaliseTabUrl(curTab.url), - favIconUrl: curTab.favIconUrl, - }); - }); - - leanSpaces.push({ - name: space.name, - tabs: leanTabs, - }); - }); - const blob = new Blob([JSON.stringify(leanSpaces)], { - type: 'application/json', - }); - const blobUrl = URL.createObjectURL(blob); - const filename = 'spaces-backup.json'; - const link = document.createElement('a'); - link.setAttribute('href', blobUrl); - link.setAttribute('download', filename); - link.click(); - }); - } - - function handleExport() { - const { sessionId } = globalSelectedSpace; - const { windowId } = globalSelectedSpace; - let csvContent = ''; - let dataString = ''; - - fetchSpaceDetail(sessionId, windowId, space => { - space.tabs.forEach(curTab => { - const url = normaliseTabUrl(curTab.url); - dataString += `${url}\n`; - }); - csvContent += dataString; - - const blob = new Blob([csvContent], { type: 'text/plain' }); - const blobUrl = URL.createObjectURL(blob); - const filename = `${space.name || 'untitled'}.txt`; - const link = document.createElement('a'); - link.setAttribute('href', blobUrl); - link.setAttribute('download', filename); - link.click(); - }); - } - - function normaliseTabUrl(url) { - let normalisedUrl = url; - if (url.indexOf('suspended.html') > 0 && url.indexOf('uri=') > 0) { - normalisedUrl = url.substring(url.indexOf('uri=') + 4, url.length); + // update cache and re-render space detail view + if (selectedSpace) { + globalSelectedSpace = selectedSpace; + updateSpaceDetail(true); + } else { + reroute(false, false, true); } - return normalisedUrl; } +} - // SERVICES +async function handleNameSave() { + const newName = nodes.nameFormInput.value; + const oldName = globalSelectedSpace.name; + const { sessionId } = globalSelectedSpace; + const { windowId } = globalSelectedSpace; - function fetchAllSpaces(callback) { - chrome.runtime.sendMessage( - { - action: 'requestAllSpaces', - }, - callback - ); + // if invalid name set then revert back to non-edit mode + if (newName === oldName || newName.trim() === '') { + updateNameForm(globalSelectedSpace); + toggleNameEditMode(false); + return; } - function fetchSpaceDetail(sessionId, windowId, callback) { - chrome.runtime.sendMessage( - { - action: 'requestSpaceDetail', - sessionId: sessionId || false, - windowId: windowId || false, - }, - callback - ); + const canOverwrite = await checkSessionOverwrite(newName); + if (!canOverwrite) { + updateNameForm(globalSelectedSpace); + toggleNameEditMode(false); + return; } - function performLoadSession(sessionId, callback) { - chrome.runtime.sendMessage( - { - action: 'loadSession', - sessionId, - }, - callback - ); - } - - function performLoadWindow(windowId, callback) { - chrome.runtime.sendMessage( - { - action: 'loadWindow', - windowId, - }, - callback - ); - } - - function performLoadTabInSession(sessionId, tabUrl, callback) { - chrome.runtime.sendMessage( - { - action: 'loadTabInSession', - sessionId, - tabUrl, - }, - callback - ); - } - - function performLoadTabInWindow(windowId, tabUrl, callback) { - chrome.runtime.sendMessage( - { - action: 'loadTabInWindow', - windowId, - tabUrl, - }, - callback - ); - } - - function performDelete(sessionId, callback) { - chrome.runtime.sendMessage( - { - action: 'deleteSession', - sessionId, - }, - callback - ); - } - - function performSessionUpdate(newName, sessionId, callback) { - chrome.runtime.sendMessage( - { - action: 'updateSessionName', - sessionName: newName, - sessionId, - }, - callback - ); - } - - function performNewSessionSave(newName, windowId, callback) { - chrome.runtime.sendMessage( - { - action: 'saveNewSession', - sessionName: newName, - windowId, - }, - callback - ); - } - - function performSessionImport(urlList, callback) { - chrome.runtime.sendMessage( - { - action: 'importNewSession', - urlList, - }, - callback - ); + // otherwise call the save service + if (sessionId) { + const session = await performSessionUpdate(newName, sessionId); + if (session) reroute(session.id, false, true); + } else if (windowId) { + const session = await performNewSessionSave(newName, windowId); + if (session) reroute(session.id, false, true); } - function performRestoreFromBackup(spacesObject, callback) { - chrome.runtime.sendMessage( - { - action: 'restoreFromBackup', - spaces: spacesObject, - }, - callback - ); + // handle banner + if (bannerState === 1) { + setBannerState(2); } +} - // EVENT LISTENERS FOR STATIC DOM ELEMENTS - - function addEventListeners() { - // register hashchange listener - window.onhashchange = () => { - updateSpacesList(); - updateSpaceDetail(); - }; - - // register incoming events listener - chrome.runtime.onMessage.addListener(request => { - if (request.action === 'updateSpaces' && request.spaces) { - handleAutoUpdateRequest(request.spaces); - } - }); - - // register dom listeners - nodes.nameFormDisplay.addEventListener('click', () => { - toggleNameEditMode(true); - }); - nodes.nameFormInput.addEventListener('blur', () => { - handleNameSave(); - }); - nodes.nameForm.addEventListener('submit', e => { - e.preventDefault(); - handleNameSave(); - }); - nodes.actionSwitch.addEventListener('click', () => { - handleLoadSpace( - globalSelectedSpace.sessionId, - globalSelectedSpace.windowId +async function handleDelete() { + const { sessionId } = globalSelectedSpace; + if (sessionId) { + const session = await fetchSpaceDetail(sessionId, false); + if (!session) { + console.error( + `handleDelete: No session found with id ${sessionId}` ); - }); - nodes.actionOpen.addEventListener('click', () => { - handleLoadSpace(globalSelectedSpace.sessionId, false); - }); - nodes.actionEdit.addEventListener('click', () => { - toggleNameEditMode(true); - }); - nodes.actionExport.addEventListener('click', () => { - handleExport(); - }); - nodes.actionBackup.addEventListener('click', () => { - handleBackup(); - }); - nodes.actionDelete.addEventListener('click', () => { - handleDelete(); - }); - nodes.actionImport.addEventListener('click', e => { - e.preventDefault(); - toggleModal(true); - }); - nodes.modalBlocker.addEventListener('click', () => { - toggleModal(false); - }); - nodes.modalButton.addEventListener('click', () => { - handleImport(); - toggleModal(false); - }); - } - - // ROUTING - - // update the hash with new ids (can trigger page re-render) - function reroute(sessionId, windowId, forceRerender) { - let hash; - - hash = '#'; - if (sessionId) { - hash += `sessionId=${sessionId}`; - } else if (windowId) { - hash += `windowId=${sessionId}`; + return; } + const sessionName = session.name || UNSAVED_SESSION_NAME; + const confirm = window.confirm( + `Are you sure you want to delete the space: ${sessionName}?` + ); - // if hash hasn't changed page will not trigger onhashchange event - if (window.location.hash === hash) { - if (forceRerender) { - updateSpacesList(); - updateSpaceDetail(); - } - - // otherwise set new hash and let the change listener call routeHash - } else { - window.location.hash = hash; + if (confirm) { + await performDelete(sessionId); + updateSpacesList(); + reroute(false, false, true); } } +} - function getVariableFromHash(key) { - if (window.location.hash.length > 0) { - const hash = window.location.hash.substr( - 1, - window.location.hash.length - ); - const pairs = hash.split('&'); +// import accepts either a newline separated list of urls or a json backup object +async function handleImport() { + let urlList; + let spaces; + + const rawInput = nodes.modalInput.value; - let matchedVal; - const match = pairs.some(curPair => { - const [curKey, curVal] = curPair.split('='); - if (curKey === key) { - matchedVal = curVal; + // check for json object + try { + spaces = JSON.parse(rawInput); + await performRestoreFromBackup(spaces); + updateSpacesList(); + } catch (e) { + // otherwise treat as a list of newline separated urls + if (rawInput.trim().length > 0) { + urlList = rawInput.split('\n'); + + // filter out bad urls + urlList = urlList.filter(url => { + if (url.trim().length > 0 && url.indexOf('://') > 0) return true; - } return false; }); - if (match) { - return matchedVal; + if (urlList.length > 0) { + const session = await performSessionImport(urlList); + if (session) reroute(session.id, false, true); } } - return false; } - - function updateSpacesList(spaces) { - // if spaces passed in then re-render immediately - if (spaces) { - renderSpacesList(spaces); - - // otherwise do a fetch of spaces first - } else { - fetchAllSpaces(newSpaces => { - renderSpacesList(newSpaces); - - // determine if welcome banner should show - initialiseBanner(newSpaces); - }); +} + +async function handleBackup() { + // strip out unnessary content from each space + const leanSpaces = (await fetchAllSpaces()).map(space => { + return { + name: space.name, + tabs: space.tabs.map(curTab => { + return { + title: curTab.title, + url: normaliseTabUrl(curTab.url), + favIconUrl: curTab.favIconUrl, + }; + }), + }; + }); + + const blob = new Blob([JSON.stringify(leanSpaces)], { + type: 'application/json', + }); + const blobUrl = URL.createObjectURL(blob); + const filename = 'spaces-backup.json'; + const link = document.createElement('a'); + link.setAttribute('href', blobUrl); + link.setAttribute('download', filename); + link.click(); +} + +async function handleExport() { + const { sessionId } = globalSelectedSpace; + const { windowId } = globalSelectedSpace; + let csvContent = ''; + let dataString = ''; + + const space = await fetchSpaceDetail(sessionId, windowId); + space.tabs.forEach(curTab => { + const url = normaliseTabUrl(curTab.url); + dataString += `${url}\n`; + }); + csvContent += dataString; + + const blob = new Blob([csvContent], { type: 'text/plain' }); + const blobUrl = URL.createObjectURL(blob); + const filename = `${space.name || 'untitled'}.txt`; + const link = document.createElement('a'); + link.setAttribute('href', blobUrl); + link.setAttribute('download', filename); + link.click(); +} + +// SERVICES + +/** @returns {Promise} */ +async function fetchAllSpaces() { + return chrome.runtime.sendMessage({ + action: 'requestAllSpaces', + }); +} + +/** @returns {Promise} */ +async function fetchSpaceDetail(sessionId, windowId) { + return chrome.runtime.sendMessage({ + action: 'requestSpaceDetail', + sessionId: sessionId || false, + windowId: windowId || false, + }); +} + +/** @returns {Promise} */ +async function performLoadSession(sessionId) { + return chrome.runtime.sendMessage({ + action: 'loadSession', + sessionId, + }); +} + +/** @returns {Promise} */ +async function performLoadWindow(windowId) { + return chrome.runtime.sendMessage({ + action: 'loadWindow', + windowId, + }); +} + +/** @returns {Promise} */ +async function performLoadTabInSession(sessionId, tabUrl) { + return chrome.runtime.sendMessage({ + action: 'loadTabInSession', + sessionId, + tabUrl, + }); +} + +/** @returns {Promise} */ +async function performLoadTabInWindow(windowId, tabUrl) { + return chrome.runtime.sendMessage({ + action: 'loadTabInWindow', + windowId, + tabUrl, + }); +} + +/** @returns {Promise} */ +async function performDelete(sessionId) { + return chrome.runtime.sendMessage({ + action: 'deleteSession', + sessionId, + }); +} + +/** @returns {Promise} */ +async function performSessionUpdate(newName, sessionId) { + return chrome.runtime.sendMessage({ + action: 'updateSessionName', + deleteOld: true, + sessionName: newName, + sessionId, + }); +} + +/** @returns {Promise} */ +async function performNewSessionSave(newName, windowId) { + return chrome.runtime.sendMessage({ + action: 'saveNewSession', + deleteOld: true, + sessionName: newName, + windowId, + }); +} + +/** @returns {Promise} */ +async function performSessionImport(urlList) { + return chrome.runtime.sendMessage({ + action: 'importNewSession', + urlList, + }); +} + +/** @returns {Promise} */ +async function performRestoreFromBackup(spaces) { + for (const space of spaces) { + const canOverwrite = await checkSessionOverwrite(space.name); + if (!canOverwrite) { + continue; } - } - - function updateSpaceDetail(useCachedSpace) { - const sessionId = getVariableFromHash('sessionId'); - const windowId = getVariableFromHash('windowId'); - const editMode = getVariableFromHash('editMode'); - // use cached currently selected space - if (useCachedSpace) { - addDuplicateMetadata(globalSelectedSpace); - renderSpaceDetail(globalSelectedSpace, editMode); + await chrome.runtime.sendMessage({ + action: 'restoreFromBackup', + deleteOld: true, + space, + }); + } +} - // otherwise refetch space based on hashvars - } else if (sessionId || windowId) { - fetchSpaceDetail(sessionId, windowId, space => { - addDuplicateMetadata(space); +// EVENT LISTENERS FOR STATIC DOM ELEMENTS - // cache current selected space - globalSelectedSpace = space; - renderSpaceDetail(space, editMode); - }); +function addEventListeners() { + // register hashchange listener + window.onhashchange = async () => { + await updateSpacesList(); + // Update the detail view using the globalSelectedSpace set by updateSpacesList + await updateSpaceDetail(true); + }; - // otherwise hide space detail view - } else { - // clear cache - globalSelectedSpace = false; - renderSpaceDetail(false, false); + // register incoming events listener + chrome.runtime.onMessage.addListener(request => { + if (request.action === 'updateSpaces' && request.spaces) { + handleAutoUpdateRequest(request.spaces); + } + }); + + // register dom listeners + nodes.nameFormDisplay.addEventListener('click', () => { + toggleNameEditMode(true); + }); + nodes.nameFormInput.addEventListener('blur', () => { + handleNameSave(); + }); + nodes.nameForm.addEventListener('submit', e => { + e.preventDefault(); + handleNameSave(); + }); + nodes.actionSwitch.addEventListener('click', () => { + handleLoadSpace( + globalSelectedSpace.sessionId, + globalSelectedSpace.windowId + ); + }); + nodes.actionOpen.addEventListener('click', () => { + handleLoadSpace(globalSelectedSpace.sessionId, false); + }); + nodes.actionEdit.addEventListener('click', () => { + toggleNameEditMode(true); + }); + nodes.actionExport.addEventListener('click', () => { + handleExport(); + }); + nodes.actionBackup.addEventListener('click', () => { + handleBackup(); + }); + nodes.actionDelete.addEventListener('click', () => { + handleDelete(); + }); + nodes.actionImport.addEventListener('click', e => { + e.preventDefault(); + toggleModal(true); + }); + nodes.modalBlocker.addEventListener('click', () => { + toggleModal(false); + }); + nodes.modalButton.addEventListener('click', () => { + handleImport(); + toggleModal(false); + }); +} + +// ROUTING + +// update the hash with new ids (can trigger page re-render) +function reroute(sessionId, windowId, forceRerender) { + let hash; + + hash = '#'; + if (sessionId) { + hash += `sessionId=${sessionId}`; + } else if (windowId) { + hash += `windowId=${sessionId}`; + } + + // if hash hasn't changed page will not trigger onhashchange event + if (window.location.hash === hash) { + if (forceRerender) { + updateSpacesList(); + updateSpaceDetail(); } - } - - function addDuplicateMetadata(space) { - const dupeCounts = {}; - - space.tabs.forEach(tab => { - // eslint-disable-next-line no-param-reassign - tab.title = tab.title || tab.url; - dupeCounts[tab.title] = dupeCounts[tab.title] - ? dupeCounts[tab.title] + 1 - : 1; - }); - space.tabs.forEach(tab => { - // eslint-disable-next-line no-param-reassign - tab.duplicate = dupeCounts[tab.title] > 1; - }); - } + // otherwise set new hash and let the change listener call routeHash + } else { + window.location.hash = hash; + } +} + +async function updateSpacesList(spaces) { + // if spaces passed in then re-render immediately + if (spaces) { + renderSpacesList(spaces); + + // otherwise do a fetch of spaces first + } else { + const newSpaces = await fetchAllSpaces(); + renderSpacesList(newSpaces); + + // determine if welcome banner should show + initialiseBanner(newSpaces); + } +} + +async function updateSpaceDetail(useCachedSpace) { + const sessionId = getHashVariable('sessionId', window.location.href); + const windowId = getHashVariable('windowId', window.location.href); + const editMode = getHashVariable('editMode', window.location.href); + + // use cached currently selected space + if (useCachedSpace) { + addDuplicateMetadata(globalSelectedSpace); + renderSpaceDetail(globalSelectedSpace, editMode); + + // otherwise refetch space based on hashvars + } else if (sessionId || windowId) { + const space = await fetchSpaceDetail(sessionId, windowId); + addDuplicateMetadata(space); + renderSpaceDetail(space, editMode); + + // otherwise hide space detail view + } else { + renderSpaceDetail(false, false); + } +} + +function addDuplicateMetadata(space) { + const dupeCounts = {}; + + space.tabs.forEach(tab => { + // eslint-disable-next-line no-param-reassign + tab.title = tab.title || tab.url; + dupeCounts[tab.title] = dupeCounts[tab.title] + ? dupeCounts[tab.title] + 1 + : 1; + }); + space.tabs.forEach(tab => { + // eslint-disable-next-line no-param-reassign + tab.duplicate = dupeCounts[tab.title] > 1; + }); +} + +/** + * Initialize the spaces window. + * This function should be called from the HTML page after the DOM is loaded. + */ +// Auto-initialize when loaded in browser context +if (typeof window !== 'undefined') { window.onload = () => { // initialise global handles to key elements (singletons) nodes.home = document.getElementById('spacesHome'); @@ -751,7 +716,7 @@ nodes.modalInput = document.getElementById('importTextArea'); nodes.modalButton = document.getElementById('importBtn'); - nodes.home.setAttribute('href', chrome.extension.getURL('spaces.html')); + nodes.home.setAttribute('href', chrome.runtime.getURL('spaces.html')); // initialise event listeners for static elements addEventListeners(); @@ -762,4 +727,31 @@ // render main content updateSpaceDetail(); }; -})(); +} + +// Module-level helper functions. + +/** + * Extracts the original URL from a Great Suspender extension suspended tab URL. + * Great Suspender URLs have the format: chrome-extension://id/suspended.html?uri=originalUrl + * + * @param {string} url - The URL to normalize (should be a string) + * @returns {string} The original URL if it's a suspended URL, otherwise returns the input unchanged + * + * @example + * normaliseTabUrl('chrome-extension://abc/suspended.html?uri=https://example.com') + * // returns: 'https://example.com' + * + * normaliseTabUrl('https://example.com') + * // returns: 'https://example.com' + */ +function normaliseTabUrl(url) { + let normalisedUrl = url; + if (url.indexOf('suspended.html') > 0 && url.indexOf('uri=') > 0) { + normalisedUrl = url.substring(url.indexOf('uri=') + 4, url.length); + } + return normalisedUrl; +} + +// Export for testing +export { normaliseTabUrl }; diff --git a/js/spacesRenderer.js b/js/spacesRenderer.js index e00de29..6f703e4 100644 --- a/js/spacesRenderer.js +++ b/js/spacesRenderer.js @@ -1,10 +1,14 @@ +import { escapeHtml } from './utils.js'; + +/** @typedef {import('./common.js').Space} Space */ + // eslint-disable-next-line no-var -var spacesRenderer = { +export const spacesRenderer = { nodes: {}, maxSuggestions: 10, oneClickMode: false, - initialise: (maxSuggestions, oneClickMode) => { + initialise(maxSuggestions, oneClickMode) { spacesRenderer.maxSuggestions = maxSuggestions; spacesRenderer.oneClickMode = oneClickMode; @@ -22,11 +26,11 @@ var spacesRenderer = { spacesRenderer.addEventListeners(); }, - renderSpaces: spaces => { - spaces.forEach(space => { + renderSpaces(spaces) { + for (const space of spaces) { const spaceEl = spacesRenderer.renderSpaceEl(space); spacesRenderer.nodes.spacesList.appendChild(spaceEl); - }); + } spacesRenderer.selectSpace(spacesRenderer.getFirstSpaceEl(), false); spacesRenderer.updateSpacesList(); @@ -34,7 +38,7 @@ var spacesRenderer = { spacesRenderer.nodes.moveInput.focus(); }, - renderSpaceEl: space => { + renderSpaceEl(space) { const listContainer = document.createElement('div'); const listTitle = document.createElement('span'); const listDetail = document.createElement('span'); @@ -54,8 +58,8 @@ var spacesRenderer = { listDetail.className = 'spaceDetail'; listTitle.innerHTML = - space.name || spacesRenderer.getDefaultSpaceTitle(space); - listDetail.innerHTML = spacesRenderer.getTabDetailsString(space); + space.name || getDefaultSpaceTitle(space); + listDetail.innerHTML = getTabDetailsString(space); listContainer.appendChild(listTitle); listContainer.appendChild(listDetail); @@ -70,37 +74,36 @@ var spacesRenderer = { return listContainer; }, - handleSpaceClick: e => { + handleSpaceClick(e) { const el = e.target.tagName === 'SPAN' ? e.target.parentElement : e.target; spacesRenderer.selectSpace(el, !spacesRenderer.oneClickMode); }, - handleSelectionNavigation: direction => { + handleSelectionNavigation(direction) { const spaceEls = document.querySelectorAll('#spacesList .space'); let prevEl = false; let selectNext = false; let selectedSpaceEl; - Array.prototype.some.call(spaceEls, el => { - if (el.style.visibility !== 'visible') return false; + for (const el of spaceEls) { + if (el.style.visibility !== 'visible') continue; // locate currently selected space if (el.className.indexOf('selected') >= 0) { if (direction === 'up' && prevEl) { selectedSpaceEl = prevEl; - return true; + break; } if (direction === 'down') { selectNext = true; } } else if (selectNext) { selectedSpaceEl = el; - return true; + break; } prevEl = el; - return false; - }); + } if (selectedSpaceEl) { spacesRenderer.selectSpace( selectedSpaceEl, @@ -109,20 +112,16 @@ var spacesRenderer = { } }, - getFirstSpaceEl: () => { - const allSpaceEls = document.querySelectorAll('#spacesList .space'); - let firstSpaceEl = false; - Array.prototype.some.call(allSpaceEls, spaceEl => { + getFirstSpaceEl() { + for (const spaceEl of document.querySelectorAll('#spacesList .space')) { if (spaceEl.style.visibility === 'visible') { - firstSpaceEl = spaceEl; - return true; + return spaceEl; } - return false; - }); - return firstSpaceEl; + } + return false; }, - selectSpace: (selectedSpaceEl, updateText) => { + selectSpace(selectedSpaceEl, updateText) { const allSpaceEls = document.querySelectorAll('#spacesList .space'); for (let i = 0; i < allSpaceEls.length; i += 1) { @@ -150,29 +149,7 @@ var spacesRenderer = { } }, - getDefaultSpaceTitle: space => { - const count = space.tabs && space.tabs.length; - if (!count) return ''; - const firstTitle = space.tabs[0].title; - if (count === 1) { - return `[${firstTitle}]`; - } - return firstTitle.length > 30 - ? `[${firstTitle.slice(0, 21)}…] +${count - 1} more` - : `[${firstTitle}] +${count - 1} more`; - }, - - getTabDetailsString: space => { - const count = space.tabs && space.tabs.length; - const open = space.windowId; - - if (open) { - return ''; - } - return `(${count} tab${count > 1 ? 's' : ''})`; - }, - - updateSpacesList: () => { + updateSpacesList() { const query = spacesRenderer.nodes.moveInput.value; let match = false; let exactMatch = false; @@ -223,7 +200,7 @@ var spacesRenderer = { spacesRenderer.selectSpace(spacesRenderer.getFirstSpaceEl(), false); }, - addEventListeners: () => { + addEventListeners() { spacesRenderer.nodes.moveInput.parentElement.parentElement.onkeyup = e => { // listen for 'up' key if (e.keyCode === 38) { @@ -251,3 +228,40 @@ var spacesRenderer = { } }, }; + +// Module-level helper functions. + +/** + * Generates a default title for a space when it hasn't been named. + * Based on the title of the first tab and the total number of tabs. + * @param {Space} space - The space object + * @returns {string} The generated title string + */ +export function getDefaultSpaceTitle(space) { + const count = space.tabs && space.tabs.length; + if (!count) return ''; + const firstTitle = space.tabs[0].title; + if (count === 1) { + return `[${escapeHtml(firstTitle)}]`; + } + return firstTitle.length > 30 + ? `[${escapeHtml(firstTitle.slice(0, 21))}…] +${count - 1} more` + : `[${escapeHtml(firstTitle)}] +${count - 1} more`; +} + +/** + * Generates a string with the number of tabs in a space. + * Returns an empty string if the space is currently open. + * @param {Space} space - The space object + * @returns {string} The generated string with the number of tabs + */ +export function getTabDetailsString(space) { + const count = space.tabs && space.tabs.length; + const open = space.windowId; + + if (open) { + return ''; + } + return `(${count} tab${count !== 1 ? 's' : ''})`; +} + diff --git a/js/spacesService.js b/js/spacesService.js deleted file mode 100644 index a889be2..0000000 --- a/js/spacesService.js +++ /dev/null @@ -1,713 +0,0 @@ -/* global chrome, dbService */ - -/* spaces - * Copyright (C) 2015 Dean Oemcke - */ - -// eslint-disable-next-line no-var -var spacesService = { - tabHistoryUrlMap: {}, - closedWindowIds: {}, - sessions: [], - sessionUpdateTimers: {}, - historyQueue: [], - eventQueueCount: 0, - lastVersion: 0, - debug: false, - - noop: () => {}, - - // initialise spaces - combine open windows with saved sessions - initialiseSpaces: () => { - // update version numbers - spacesService.lastVersion = spacesService.fetchLastVersion(); - spacesService.setLastVersion(chrome.runtime.getManifest().version); - - dbService.fetchAllSessions(sessions => { - if ( - chrome.runtime.getManifest().version === '0.18' && - chrome.runtime.getManifest().version !== - spacesService.lastVersion - ) { - spacesService.resetAllSessionHashes(sessions); - } - - chrome.windows.getAll({ populate: true }, windows => { - // populate session map from database - spacesService.sessions = sessions; - - // clear any previously saved windowIds - spacesService.sessions.forEach(session => { - // eslint-disable-next-line no-param-reassign - session.windowId = false; - }); - - // then try to match current open windows with saved sessions - windows.forEach(curWindow => { - if (!spacesService.filterInternalWindows(curWindow)) { - spacesService.checkForSessionMatch(curWindow); - } - }); - }); - }); - }, - - resetAllSessionHashes: sessions => { - sessions.forEach(session => { - // eslint-disable-next-line no-param-reassign - session.sessionHash = spacesService.generateSessionHash( - session.tabs - ); - dbService.updateSession(session); - }); - }, - - // record each tab's id and url so we can add history items when tabs are removed - initialiseTabHistory: () => { - chrome.tabs.query({}, tabs => { - tabs.forEach(tab => { - spacesService.tabHistoryUrlMap[tab.id] = tab.url; - }); - }); - }, - - // NOTE: if ever changing this funciton, then we'll need to update all - // saved sessionHashes so that they match next time, using: resetAllSessionHashes() - _cleanUrl: url => { - if (!url) { - return ''; - } - - // ignore urls from this extension - if (url.indexOf(chrome.runtime.id) >= 0) { - return ''; - } - - // ignore 'new tab' pages - if (url.indexOf('chrome:// newtab/') >= 0) { - return ''; - } - - let cleanUrl = url; - - // add support for 'The Great Suspender' - if ( - cleanUrl.indexOf('suspended.html') > 0 && - cleanUrl.indexOf('uri=') > 0 - ) { - cleanUrl = cleanUrl.substring( - cleanUrl.indexOf('uri=') + 4, - cleanUrl.length - ); - } - - // remove any text after a '#' symbol - if (cleanUrl.indexOf('#') > 0) { - cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf('#')); - } - - // remove any text after a '?' symbol - if (cleanUrl.indexOf('?') > 0) { - cleanUrl = cleanUrl.substring(0, cleanUrl.indexOf('?')); - } - - return cleanUrl; - }, - - generateSessionHash: tabs => { - const text = tabs.reduce((prevStr, tab) => { - return prevStr + spacesService._cleanUrl(tab.url); - }, ''); - - let hash = 0; - if (text.length === 0) return hash; - for (let i = 0, len = text.length; i < len; i += 1) { - const chr = text.charCodeAt(i); - // eslint-disable-next-line no-bitwise - hash = (hash << 5) - hash + chr; - // eslint-disable-next-line no-bitwise - hash |= 0; // Convert to 32bit integer - } - return Math.abs(hash); - }, - - filterInternalWindows: curWindow => { - // sanity check to make sure window isnt an internal spaces window - if ( - curWindow.tabs.length === 1 && - curWindow.tabs[0].url.indexOf(chrome.runtime.id) >= 0 - ) { - return true; - } - - // also filter out popup or panel window types - if (curWindow.type === 'popup' || curWindow.type === 'panel') { - return true; - } - return false; - }, - - checkForSessionMatch: curWindow => { - if (!curWindow.tabs || curWindow.tabs.length === 0) { - return; - } - - const sessionHash = spacesService.generateSessionHash(curWindow.tabs); - const temporarySession = spacesService.getSessionByWindowId( - curWindow.id - ); - const matchingSession = spacesService.getSessionBySessionHash( - sessionHash, - true - ); - - if (matchingSession) { - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log( - `matching session found: ${matchingSession.id}. linking with window: ${curWindow.id}` - ); - - spacesService.matchSessionToWindow(matchingSession, curWindow); - } - - // if no match found and this window does not already have a temporary session - if (!matchingSession && !temporarySession) { - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log( - `no matching session found. creating temporary session for window: ${curWindow.id}` - ); - - // create a new temporary session for this window (with no sessionId or name) - spacesService.createTemporaryUnmatchedSession(curWindow); - } - }, - - matchSessionToWindow: (session, curWindow) => { - // remove any other sessions tied to this windowId (temporary sessions) - for (let i = spacesService.sessions.length - 1; i >= 0; i -= 1) { - if (spacesService.sessions[i].windowId === curWindow.id) { - if (spacesService.sessions[i].id) { - spacesService.sessions[i].windowId = false; - } else { - spacesService.sessions.splice(i, 1); - } - } - } - - // assign windowId to newly matched session - // eslint-disable-next-line no-param-reassign - session.windowId = curWindow.id; - }, - - createTemporaryUnmatchedSession: curWindow => { - if (spacesService.debug) { - // eslint-disable-next-line no-console - console.dir(spacesService.sessions); - // eslint-disable-next-line no-console - console.dir(curWindow); - // eslint-disable-next-line no-alert - alert('couldnt match window. creating temporary session'); - } - - const sessionHash = spacesService.generateSessionHash(curWindow.tabs); - - spacesService.sessions.push({ - id: false, - windowId: curWindow.id, - sessionHash, - name: false, - tabs: curWindow.tabs, - history: [], - lastAccess: new Date(), - }); - }, - - // local storage getters/setters - fetchLastVersion: () => { - let version = localStorage.getItem('spacesVersion'); - if (version !== null) { - version = JSON.parse(version); - return version; - } - return 0; - }, - - setLastVersion: newVersion => { - localStorage.setItem('spacesVersion', JSON.stringify(newVersion)); - }, - - // event listener functions for window and tab events - // (events are received and screened first in background.js) - // ----------------------------------------------------------------------------------------- - - handleTabRemoved: (tabId, removeInfo, callback) => { - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log( - `handlingTabRemoved event. windowId: ${removeInfo.windowId}` - ); - - // NOTE: isWindowClosing is true if the window cross was clicked causing the tab to be removed. - // If the tab cross is clicked and it is the last tab in the window - // isWindowClosing will still be false even though the window will close - if (removeInfo.isWindowClosing) { - // be very careful here as we definitley do not want these removals being saved - // as part of the session (effectively corrupting the session) - - // should be handled by the window removed listener - spacesService.handleWindowRemoved( - removeInfo.windowId, - true, - spacesService.noop - ); - - // if this is a legitimate single tab removal from a window then update session/window - } else { - spacesService.historyQueue.push({ - url: spacesService.tabHistoryUrlMap[tabId], - windowId: removeInfo.windowId, - action: 'add', - }); - spacesService.queueWindowEvent( - removeInfo.windowId, - spacesService.eventQueueCount, - callback - ); - - // remove tab from tabHistoryUrlMap - delete spacesService.tabHistoryUrlMap[tabId]; - } - }, - handleTabMoved: (tabId, moveInfo, callback) => { - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log( - `handlingTabMoved event. windowId: ${moveInfo.windowId}` - ); - spacesService.queueWindowEvent( - moveInfo.windowId, - spacesService.eventQueueCount, - callback - ); - }, - handleTabUpdated: (tab, changeInfo, callback) => { - // NOTE: only queue event when tab has completed loading (title property exists at this point) - if (tab.status === 'complete') { - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log( - `handlingTabUpdated event. windowId: ${tab.windowId}` - ); - - // update tab history in case the tab url has changed - spacesService.tabHistoryUrlMap[tab.id] = tab.url; - spacesService.queueWindowEvent( - tab.windowId, - spacesService.eventQueueCount, - callback - ); - } - - // check for change in tab url. if so, update history - if (changeInfo.url) { - // add tab to history queue as an item to be removed (as it is open for this window) - spacesService.historyQueue.push({ - url: changeInfo.url, - windowId: tab.windowId, - action: 'remove', - }); - } - }, - handleWindowRemoved: (windowId, markAsClosed, callback) => { - // ignore subsequent windowRemoved events for the same windowId (each closing tab will try to call this) - if (spacesService.closedWindowIds[windowId]) { - callback(); - } - - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log(`handlingWindowRemoved event. windowId: ${windowId}`); - - // add windowId to closedWindowIds. the idea is that once a window is closed it can never be - // rematched to a new session (hopefully these window ids never get legitimately re-used) - if (markAsClosed) { - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log(`adding window to closedWindowIds: ${windowId}`); - spacesService.closedWindowIds[windowId] = true; - clearTimeout(spacesService.sessionUpdateTimers[windowId]); - } - - const session = spacesService.getSessionByWindowId(windowId); - if (session) { - // if this is a saved session then just remove the windowId reference - if (session.id) { - session.windowId = false; - - // else if it is temporary session then remove the session from the cache - } else { - spacesService.sessions.some((curSession, index) => { - if (curSession.windowId === windowId) { - spacesService.sessions.splice(index, 1); - return true; - } - return false; - }); - } - } - - callback(); - }, - handleWindowFocussed: windowId => { - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log(`handlingWindowFocussed event. windowId: ${windowId}`); - - if (windowId <= 0) { - return; - } - - const session = spacesService.getSessionByWindowId(windowId); - if (session) { - session.lastAccess = new Date(); - } - }, - - // 1sec timer-based batching system. - // Set a timeout so that multiple tabs all opened at once (like when restoring a session) - // only trigger this function once (as per the timeout set by the last tab event) - // This will cause multiple triggers if time between tab openings is longer than 1 sec - queueWindowEvent: (windowId, eventId, callback) => { - clearTimeout(spacesService.sessionUpdateTimers[windowId]); - - spacesService.eventQueueCount += 1; - - spacesService.sessionUpdateTimers[windowId] = setTimeout(() => { - spacesService.handleWindowEvent(windowId, eventId, callback); - }, 1000); - }, - - // careful here as this function gets called A LOT - handleWindowEvent: (windowId, eventId, callback) => { - // eslint-disable-next-line no-param-reassign - callback = - typeof callback !== 'function' ? spacesService.noop : callback; - - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log('------------------------------------------------'); - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log( - `event: ${eventId}. attempting session update. windowId: ${windowId}` - ); - - // sanity check windowId - if (!windowId || windowId <= 0) { - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log( - `received an event for windowId: ${windowId} which is obviously wrong` - ); - return; - } - - chrome.windows.get(windowId, { populate: true }, curWindow => { - if (chrome.runtime.lastError) { - // eslint-disable-next-line no-console - console.log( - `${chrome.runtime.lastError.message}. perhaps its the development console???` - ); - - // if we can't find this window, then better remove references to it from the cached sessions - // don't mark as a removed window however, so that the space can be resynced up if the window - // does actually still exist (for some unknown reason) - spacesService.handleWindowRemoved( - windowId, - false, - spacesService.noop - ); - return; - } - - if (!curWindow || spacesService.filterInternalWindows(curWindow)) { - return; - } - - // don't allow event if it pertains to a closed window id - if (spacesService.closedWindowIds[windowId]) { - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log( - `ignoring event as it pertains to a closed windowId: ${windowId}` - ); - return; - } - - // if window is associated with an open session then update session - const session = spacesService.getSessionByWindowId(windowId); - - if (session) { - if (spacesService.debug) - // eslint-disable-next-line no-console - console.log( - `tab statuses: ${curWindow.tabs - .map(curTab => { - return curTab.status; - }) - .join('|')}` - ); - - // look for tabs recently added/removed from this session and update session history - const historyItems = spacesService.historyQueue.filter( - historyItem => { - return historyItem.windowId === windowId; - } - ); - - for (let i = historyItems.length - 1; i >= 0; i -= 1) { - const historyItem = historyItems[i]; - - if (historyItem.action === 'add') { - spacesService.addUrlToSessionHistory( - session, - historyItem.url - ); - } else if (historyItem.action === 'remove') { - spacesService.removeUrlFromSessionHistory( - session, - historyItem.url - ); - } - spacesService.historyQueue.splice(i, 1); - } - - // override session tabs with tabs from window - session.tabs = curWindow.tabs; - session.sessionHash = spacesService.generateSessionHash( - session.tabs - ); - - // if it is a saved session then update db - if (session.id) { - spacesService.saveExistingSession(session.id); - } - } - - // if no session found, it must be a new window. - // if session found without session.id then it must be a temporary session - // check for sessionMatch - if (!session || !session.id) { - if (spacesService.debug) { - // eslint-disable-next-line no-console - console.log('session check triggered'); - } - spacesService.checkForSessionMatch(curWindow); - } - callback(); - }); - }, - - // PUBLIC FUNCTIONS - - getSessionBySessionId: sessionId => { - const result = spacesService.sessions.filter(session => { - return session.id === sessionId; - }); - return result.length === 1 ? result[0] : false; - }, - getSessionByWindowId: windowId => { - const result = spacesService.sessions.filter(session => { - return session.windowId === windowId; - }); - return result.length === 1 ? result[0] : false; - }, - getSessionBySessionHash: (hash, closedOnly) => { - const result = spacesService.sessions.filter(session => { - if (closedOnly) { - return session.sessionHash === hash && !session.windowId; - } - return session.sessionHash === hash; - }); - return result.length >= 1 ? result[0] : false; - }, - getSessionByName: name => { - const result = spacesService.sessions.filter(session => { - return ( - session.name && - session.name.toLowerCase() === name.toLowerCase() - ); - }); - return result.length >= 1 ? result[0] : false; - }, - getAllSessions: () => { - return spacesService.sessions; - }, - - addUrlToSessionHistory: (session, newUrl) => { - if (spacesService.debug) { - // eslint-disable-next-line no-console - console.log(`adding tab to history: ${newUrl}`); - } - - const cleanUrl = spacesService._cleanUrl(newUrl); - - if (cleanUrl.length === 0) { - return false; - } - - // don't add removed tab to history if there is still a tab open with same url - // note: assumes tab has NOT already been removed from session.tabs - const tabBeingRemoved = session.tabs.filter(curTab => { - return spacesService._cleanUrl(curTab.url) === cleanUrl; - }); - - if (tabBeingRemoved.length !== 1) { - return false; - } - - // eslint-disable-next-line no-param-reassign - if (!session.history) session.history = []; - - // see if tab already exists in history. if so then remove it (it will be re-added) - session.history.some((historyTab, index) => { - if (spacesService._cleanUrl(historyTab.url) === cleanUrl) { - session.history.splice(index, 1); - return true; - } - return false; - }); - - // add url to session history - // eslint-disable-next-line no-param-reassign - session.history = tabBeingRemoved.concat(session.history); - - // trim history for this space down to last 200 items - // eslint-disable-next-line no-param-reassign - session.history = session.history.slice(0, 200); - - return session; - }, - - removeUrlFromSessionHistory: (session, newUrl) => { - if (spacesService.debug) { - // eslint-disable-next-line no-console - console.log(`removing tab from history: ${newUrl}`); - } - - // eslint-disable-next-line no-param-reassign - newUrl = spacesService._cleanUrl(newUrl); - - if (newUrl.length === 0) { - return; - } - - // see if tab already exists in history. if so then remove it - session.history.some((historyTab, index) => { - if (spacesService._cleanUrl(historyTab.url) === newUrl) { - session.history.splice(index, 1); - return true; - } - return false; - }); - }, - - // Database actions - - updateSessionTabs: (sessionId, tabs, callback) => { - const session = spacesService.getSessionBySessionId(sessionId); - - // eslint-disable-next-line no-param-reassign - callback = - typeof callback !== 'function' ? spacesService.noop : callback; - - // update tabs in session - session.tabs = tabs; - session.sessionHash = spacesService.generateSessionHash(session.tabs); - - spacesService.saveExistingSession(session.id, callback); - }, - - updateSessionName: (sessionId, sessionName, callback) => { - // eslint-disable-next-line no-param-reassign - callback = - typeof callback !== 'function' ? spacesService.noop : callback; - - const session = spacesService.getSessionBySessionId(sessionId); - session.name = sessionName; - - spacesService.saveExistingSession(session.id, callback); - }, - - saveExistingSession: (sessionId, callback) => { - const session = spacesService.getSessionBySessionId(sessionId); - - // eslint-disable-next-line no-param-reassign - callback = - typeof callback !== 'function' ? spacesService.noop : callback; - - dbService.updateSession(session, callback); - }, - - saveNewSession: (sessionName, tabs, windowId, callback) => { - if (!tabs) { - callback(); - return; - } - - const sessionHash = spacesService.generateSessionHash(tabs); - let session; - - // eslint-disable-next-line no-param-reassign - callback = - typeof callback !== 'function' ? spacesService.noop : callback; - - // check for a temporary session with this windowId - if (windowId) { - session = spacesService.getSessionByWindowId(windowId); - } - - // if no temporary session found with this windowId, then create one - if (!session) { - session = { - windowId, - history: [], - }; - spacesService.sessions.push(session); - } - - // update temporary session details - session.name = sessionName; - session.sessionHash = sessionHash; - session.tabs = tabs; - session.lastAccess = new Date(); - - // save session to db - dbService.createSession(session, savedSession => { - // update sessionId in cache - session.id = savedSession.id; - - callback(savedSession); - }); - }, - - deleteSession: (sessionId, callback) => { - // eslint-disable-next-line no-param-reassign - callback = - typeof callback !== 'function' ? spacesService.noop : callback; - - dbService.removeSession(sessionId, () => { - // remove session from cached array - spacesService.sessions.some((session, index) => { - if (session.id === sessionId) { - spacesService.sessions.splice(index, 1); - return true; - } - return false; - }); - callback(); - }); - }, -}; diff --git a/js/switcher.js b/js/switcher.js index 741780d..d90f0c2 100644 --- a/js/switcher.js +++ b/js/switcher.js @@ -1,90 +1,85 @@ /* global chrome, spacesRenderer */ -(() => { - function getSelectedSpace() { - return document.querySelector('.space.selected'); - } +import { spacesRenderer } from './spacesRenderer.js'; - function handleSwitchAction(selectedSpaceEl) { - chrome.runtime.sendMessage({ - action: 'switchToSpace', - sessionId: selectedSpaceEl.getAttribute('data-sessionId'), - windowId: selectedSpaceEl.getAttribute('data-windowId'), - }); - } +function getSelectedSpace() { + return document.querySelector('.space.selected'); +} - function handleCloseAction() { - chrome.runtime.sendMessage({ - action: 'requestClose', - }); - } +function handleSwitchAction(selectedSpaceEl) { + chrome.runtime.sendMessage({ + action: 'switchToSpace', + sessionId: selectedSpaceEl.getAttribute('data-sessionId'), + windowId: selectedSpaceEl.getAttribute('data-windowId'), + }); +} - function getSwitchKeycodes(callback) { - chrome.runtime.sendMessage({ action: 'requestHotkeys' }, commands => { - // eslint-disable-next-line no-console - console.dir(commands); +function handleCloseAction() { + chrome.runtime.sendMessage({ + action: 'requestClose', + }); +} - const commandStr = commands.switchCode; - const keyStrArray = commandStr.split('+'); +async function getSwitchKeycodes(callback) { + const commands = await chrome.commands.getAll(); + const commandStr = commands.switchCode; + const keyStrArray = commandStr.split('+'); - // get keyStr of primary modifier - const primaryModifier = keyStrArray[0]; + // get keyStr of primary modifier + const primaryModifier = keyStrArray[0]; - // get keyStr of secondary modifier - const secondaryModifier = - keyStrArray.length === 3 ? keyStrArray[1] : false; + // get keyStr of secondary modifier + const secondaryModifier = + keyStrArray.length === 3 ? keyStrArray[1] : false; - // get keycode of main key (last in array) - const curStr = keyStrArray[keyStrArray.length - 1]; - let mainKeyCode; + // get keycode of main key (last in array) + const curStr = keyStrArray[keyStrArray.length - 1]; + let mainKeyCode; - // TODO: There's others. Period. Up Arrow etc. - if (curStr === 'Space') { - mainKeyCode = 32; - } else { - mainKeyCode = curStr.toUpperCase().charCodeAt(); - } - - callback({ - primaryModifier, - secondaryModifier, - mainKeyCode, - }); - }); + // TODO: There's others. Period. Up Arrow etc. + if (curStr === 'Space') { + mainKeyCode = 32; + } else { + mainKeyCode = curStr.toUpperCase().charCodeAt(); } - function addEventListeners() { - document.getElementById('spaceSelectForm').onsubmit = e => { - e.preventDefault(); - handleSwitchAction(getSelectedSpace()); - }; + callback({ + primaryModifier, + secondaryModifier, + mainKeyCode, + }); +} - const allSpaceEls = document.querySelectorAll('.space'); - Array.prototype.forEach.call(allSpaceEls, el => { - // eslint-disable-next-line no-param-reassign - el.onclick = () => { - handleSwitchAction(el); - }; - }); +function addEventListeners() { + document.getElementById('spaceSelectForm').onsubmit = e => { + e.preventDefault(); + handleSwitchAction(getSelectedSpace()); + }; - // Here lies some pretty hacky stuff. Yus! Hax! - getSwitchKeycodes(() => { - const body = document.querySelector('body'); + const allSpaceEls = document.querySelectorAll('.space'); + Array.prototype.forEach.call(allSpaceEls, el => { + // eslint-disable-next-line no-param-reassign + el.onclick = () => { + handleSwitchAction(el); + }; + }); - body.onkeyup = e => { - // listen for escape key - if (e.keyCode === 27) { - handleCloseAction(); - } - }; - }); - } + // Here lies some pretty hacky stuff. Yus! Hax! + getSwitchKeycodes(() => { + const body = document.querySelector('body'); - window.onload = () => { - chrome.runtime.sendMessage({ action: 'requestAllSpaces' }, spaces => { - spacesRenderer.initialise(8, true); - spacesRenderer.renderSpaces(spaces); - addEventListeners(); - }); - }; -})(); + body.onkeyup = e => { + // listen for escape key + if (e.keyCode === 27) { + handleCloseAction(); + } + }; + }); +} + +window.onload = async () => { + const spaces = await chrome.runtime.sendMessage({ action: 'requestAllSpaces' }); + spacesRenderer.initialise(8, true); + spacesRenderer.renderSpaces(spaces); + addEventListeners(); +}; diff --git a/js/utils.js b/js/utils.js index 90e6a35..2899241 100644 --- a/js/utils.js +++ b/js/utils.js @@ -1,63 +1,59 @@ -/* global chrome */ -// eslint-disable-next-line no-var, no-unused-vars -var utils = { - getHashVariable: (key, urlStr) => { - const valuesByKey = {}; - const keyPairRegEx = /^(.+)=(.+)/; - - if (!urlStr || urlStr.length === 0 || urlStr.indexOf('#') === -1) { - return false; - } - - // extract hash component from url - const hashStr = urlStr.replace(/^[^#]+#+(.*)/, '$1'); - - if (hashStr.length === 0) { - return false; - } - - hashStr.split('&').forEach(keyPair => { - if (keyPair && keyPair.match(keyPairRegEx)) { - valuesByKey[ - keyPair.replace(keyPairRegEx, '$1') - ] = keyPair.replace(keyPairRegEx, '$2'); - } - }); - return valuesByKey[key] || false; - }, - - getSwitchKeycodes: callback => { - chrome.runtime.sendMessage({ action: 'requestHotkeys' }, commands => { - // eslint-disable-next-line no-console - console.dir(commands); +/** + * @fileoverview Client-side utility functions for the Spaces Chrome extension. + * + * This module contains utility functions that are only used by client-side code + * (popup, spaces window, content scripts, etc.). Functions that need to be shared + * between client-side and background scripts should be placed in common.js instead. + */ - const commandStr = commands.switchCode; - - const keyStrArray = commandStr.split('+'); - - // get keyStr of primary modifier - const primaryModifier = keyStrArray[0]; - - // get keyStr of secondary modifier - const secondaryModifier = - keyStrArray.length === 3 ? keyStrArray[1] : false; - - // get keycode of main key (last in array) - const curStr = keyStrArray[keyStrArray.length - 1]; - - // TODO: There's others. Period. Up Arrow etc. - let mainKeyCode; - if (curStr === 'Space') { - mainKeyCode = 32; - } else { - mainKeyCode = curStr.toUpperCase().charCodeAt(); - } +/* global chrome */ - callback({ - primaryModifier, - secondaryModifier, - mainKeyCode, - }); - }); - }, -}; +/** @typedef {import('./common.js').SessionPresence} SessionPresence */ + +/** + * Checks if a session with the given name can be overwritten by checking + * with the background script, alerting the user if the session is currently + * open, and confirming if the session already exists but is not open. + * @param {string} sessionName + * @returns {Promise} Returns true if the session can be safely + * overwritten. This happens if the session does not exist or if the + * user has confirmed overwriting. + */ +export async function checkSessionOverwrite(sessionName) { + /** @type {SessionPresence} */ + const sessionPresence = await chrome.runtime.sendMessage({ + action: 'requestSessionPresence', + sessionName, + }); + + if (!sessionPresence.exists) { + return true; + } + + if (sessionPresence.isOpen) { + // eslint-disable-next-line no-alert + alert( + `A session with the name '${sessionName}' is currently open and cannot be overwritten` + ); + return false; + } + return confirm( + `A session with the name '${sessionName}' already exists. Do you want to overwrite it?` + ); +} + +/** + * Escapes HTML characters to prevent XSS and HTML injection. + * @param {string} text - The text to escape + * @returns {string} The HTML-escaped text + */ +export function escapeHtml(text) { + if (!text) return text; + + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/manifest.json b/manifest.json index 06b5114..995897d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,26 +1,21 @@ { "name": "Spaces", "description": "Intuitive tab management", - "version": "1.1.3", + "version": "1.1.4", "permissions": [ - "tabs", - "storage", + "contextMenus", + "favicon", "history", - "unlimitedStorage", - "chrome://favicon/*", - "contextMenus" + "storage", + "system.display", + "tabs", + "unlimitedStorage" ], "background": { - "scripts": [ - "js/db.js", - "js/dbService.js", - "js/spacesService.js", - "js/utils.js", - "js/background.js" - ], - "persistent": true + "service_worker": "js/background/main.js", + "type": "module" }, - "browser_action": { + "action": { "default_title": "Spaces", "default_icon": "img/icon128.png", "default_popup": "popup.html" @@ -31,9 +26,8 @@ "48": "img/icon48.png", "128": "img/icon128.png" }, - "web_accessible_resources": [], "incognito": "split", - "manifest_version": 2, + "manifest_version": 3, "minimum_chrome_version": "35", "commands": { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..45304a6 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3661 @@ +{ + "name": "spaces", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "jest": "^29.7.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", + "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.3", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.3", + "@babel/types": "^7.28.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.3.tgz", + "integrity": "sha512-PTNtvUQihsAsDHMOP5pfobP8C6CM4JWXmP8DrEIt46c3r2bf87Ua1zoqevsMo9g+tWDwgWrFP5EIxuBx5RudAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.3.tgz", + "integrity": "sha512-7+Ey1mAgYqFAx2h0RuoxcQT5+MlG3GTV0TQrgr7/ZliKsm/MNDxVVutlWaziMq7wJNAz8MTqz55XLpWvva6StA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.3.tgz", + "integrity": "sha512-7w4kZYHneL3A6NP2nxzHvT3HCZ7puDZZjFMqDpBPECub79sTtSO5CGXDkKrTQq8ksAwfD/XI2MRFX23njdDaIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.3", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.2", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", + "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.30", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.30.tgz", + "integrity": "sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.2.1.tgz", + "integrity": "sha512-Cg3ymMAdN10wOk/VYfLV7KCQyv7EDirJ64500sU7n9UlmioEtDuU5Gd+hj73hXSU/ex7tHJSssmyftDdkMLO8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.5.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.5.2.tgz", + "integrity": "sha512-DWkS49EQKVX//Tbupb9TFa19c7+MK1XmzkrZUR8TAktmE/DizXoaoJV6TZ/tSIPXipqNiRI6CyAe7x69Jb6RSw==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c= sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.4.tgz", + "integrity": "sha512-4jYpcjabC606xJ3kw2QwGEZKX0Aw7sgQdZCvIK9dhVSPh76BKo+C+btT1RRofH7B+8iNpEbgGNVWiLki5q93yg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001737", + "electron-to-chromium": "^1.5.211", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001739", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001739.tgz", + "integrity": "sha512-y+j60d6ulelrNSwpPyrHdl+9mJnQzHBr08xm48Qno0nSk4h3Qojh+ziv2qE6rXf4k3tadF4o1J/1tAbVm1NtnA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.211", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.211.tgz", + "integrity": "sha512-IGBvimJkotaLzFnwIVgW9/UD/AOJ2tByUmeOrtqBfACSbAw5b1G0XpvdaieKyc7ULmbwXVx+4e4Be8pOPBrYkw==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8= sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local/node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o= sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-instrument/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E= sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18= sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json index cae7071..8bc8b42 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,19 @@ { + "type": "module", + "scripts": { + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage" + }, + "jest": { + "collectCoverageFrom": [ + "js/**/*.js", + "!js/**/*.test.js", + "!**/node_modules/**" + ], + "testEnvironment": "node" + }, "devDependencies": { - "eslint": "^6.7.1", - "eslint-config-airbnb-base": "^14.0.0", - "eslint-config-prettier": "^6.5.0", - "eslint-config-standard": "^14.1.0", - "eslint-plugin-import": "^2.18.2", - "eslint-plugin-node": "^10.0.0", - "eslint-plugin-prettier": "^3.1.1", - "eslint-plugin-promise": "^4.2.1", - "eslint-plugin-standard": "^4.0.1", - "prettier": "^1.19.1" + "jest": "^29.7.0" } } diff --git a/popup.html b/popup.html index 520c4ad..76200d0 100644 --- a/popup.html +++ b/popup.html @@ -4,8 +4,8 @@ - - + + diff --git a/spaces.html b/spaces.html index 406c72d..5d2f45b 100644 --- a/spaces.html +++ b/spaces.html @@ -7,7 +7,7 @@ Spaces - + diff --git a/test_escaping_demo.html b/test_escaping_demo.html new file mode 100644 index 0000000..5d0b3c8 --- /dev/null +++ b/test_escaping_demo.html @@ -0,0 +1,44 @@ + + + + HTML Escaping Bug Fix Demo + + +

HTML Escaping Bug Fix Demo

+ +
+

Before Fix (Vulnerable):

+
+ +

After Fix (Safe):

+
+
+ + + + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..1f97ad6 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,26 @@ +# Unit Tests for Spaces Chrome Extension + +This directory contains unit tests for the Spaces Chrome extension using **Jest**. This is the first step toward better code quality and maintainability. + +## Getting Started + +### Prerequisites + +Make sure you have Node.js installed and run: + +```bash +npm install +``` + +### Running Tests + +```bash +# Run all tests +npm test + +# Run tests in watch mode (re-runs when files change) +npm run test:watch + +# Run tests with coverage report +npm run test:coverage +``` diff --git a/tests/addSessionSafely.test.js b/tests/addSessionSafely.test.js new file mode 100644 index 0000000..034aad8 --- /dev/null +++ b/tests/addSessionSafely.test.js @@ -0,0 +1,140 @@ +import { spacesService } from '../js/background/spacesService.js'; + +describe('_addSessionSafely', () => { + beforeEach(() => { + // Reset sessions array before each test + spacesService.sessions = []; + }); + + test('adds new temporary session successfully', () => { + const newSession = { + id: false, + windowId: 100, + name: false, + tabs: [{ url: 'https://example.com' }] + }; + + const result = spacesService._addSessionSafely(newSession); + + expect(result).toBe(true); + expect(spacesService.sessions).toHaveLength(1); + expect(spacesService.sessions[0]).toEqual(newSession); + }); + + test('prevents duplicate temporary sessions by windowId', () => { + const existingSession = { + id: false, + windowId: 100, + name: false, + tabs: [{ url: 'https://example.com' }] + }; + + const duplicateSession = { + id: false, + windowId: 100, + name: 'Different name', + tabs: [{ url: 'https://different.com' }] + }; + + // Add first session + spacesService.sessions.push(existingSession); + + // Try to add duplicate + const result = spacesService._addSessionSafely(duplicateSession); + + expect(result).toBe(false); + expect(spacesService.sessions).toHaveLength(1); + expect(spacesService.sessions[0]).toEqual(existingSession); + }); + + test('allows multiple temporary sessions with different windowIds', () => { + const session1 = { + id: false, + windowId: 100, + name: false, + tabs: [{ url: 'https://example.com' }] + }; + + const session2 = { + id: false, + windowId: 200, + name: false, + tabs: [{ url: 'https://different.com' }] + }; + + const result1 = spacesService._addSessionSafely(session1); + const result2 = spacesService._addSessionSafely(session2); + + expect(result1).toBe(true); + expect(result2).toBe(true); + expect(spacesService.sessions).toHaveLength(2); + }); + + test('prevents duplicate sessions by windowId regardless of session type', () => { + const existingSession = { + id: false, + windowId: 100, + name: false, + tabs: [{ url: 'https://example.com' }] + }; + + const savedSession = { + id: 1, + windowId: 100, // Same windowId as temporary session + name: 'Saved Session', + tabs: [{ url: 'https://saved.com' }] + }; + + spacesService.sessions.push(existingSession); + const result = spacesService._addSessionSafely(savedSession); + + expect(result).toBe(false); + expect(spacesService.sessions).toHaveLength(1); + expect(spacesService.sessions[0]).toEqual(existingSession); + }); + + test('allows saved sessions with different windowIds', () => { + const existingSession = { + id: false, + windowId: 100, + name: false, + tabs: [{ url: 'https://example.com' }] + }; + + const savedSession = { + id: 1, + windowId: 200, // Different windowId + name: 'Saved Session', + tabs: [{ url: 'https://saved.com' }] + }; + + spacesService.sessions.push(existingSession); + const result = spacesService._addSessionSafely(savedSession); + + expect(result).toBe(true); + expect(spacesService.sessions).toHaveLength(2); + }); + + test('allows temporary sessions without windowId', () => { + const session1 = { + id: false, + windowId: false, + name: false, + tabs: [{ url: 'https://example.com' }] + }; + + const session2 = { + id: false, + windowId: false, + name: false, + tabs: [{ url: 'https://different.com' }] + }; + + const result1 = spacesService._addSessionSafely(session1); + const result2 = spacesService._addSessionSafely(session2); + + expect(result1).toBe(true); + expect(result2).toBe(true); + expect(spacesService.sessions).toHaveLength(2); + }); +}); diff --git a/tests/cleanParameter.test.js b/tests/cleanParameter.test.js new file mode 100644 index 0000000..2445808 --- /dev/null +++ b/tests/cleanParameter.test.js @@ -0,0 +1,104 @@ +/** + * @jest-environment node + */ + +import { cleanParameter } from '../js/background/background.js'; + +describe('cleanParameter', () => { + describe('number inputs', () => { + test('should return numbers unchanged', () => { + expect(cleanParameter(42)).toBe(42); + expect(cleanParameter(0)).toBe(0); + expect(cleanParameter(-5)).toBe(-5); + expect(cleanParameter(3.14)).toBe(3.14); + }); + }); + + describe('boolean string inputs', () => { + test('should convert boolean strings to boolean values', () => { + expect(cleanParameter('false')).toBe(false); + expect(cleanParameter('true')).toBe(true); + }); + + test('should be case-sensitive for boolean strings', () => { + // These should not be treated as booleans + expect(cleanParameter('False')).toBe(NaN); + }); + }); + + describe('numeric string inputs', () => { + test('should parse numeric strings to integers', () => { + expect(cleanParameter('123')).toBe(123); + expect(cleanParameter('0')).toBe(0); + expect(cleanParameter('-5')).toBe(-5); + }); + + test('should parse decimal strings as integers', () => { + // parseInt truncates decimals + expect(cleanParameter('3.14')).toBe(3); + }); + + test('should handle numeric strings with whitespace', () => { + expect(cleanParameter(' 42 ')).toBe(42); + }); + }); + + describe('edge cases and invalid inputs', () => { + test('should handle null and undefined', () => { + expect(cleanParameter(null)).toBe(NaN); + }); + + test('should handle empty string', () => { + expect(cleanParameter('')).toBe(NaN); + }); + + test('should handle non-numeric strings', () => { + expect(cleanParameter('abc')).toBe(NaN); + }); + + test('should handle strings that start with numbers', () => { + // parseInt parses until it hits non-numeric character + expect(cleanParameter('123abc')).toBe(123); + }); + + test('should handle special values', () => { + expect(cleanParameter('Infinity')).toBe(NaN); + }); + + test('should handle objects and arrays', () => { + expect(cleanParameter({})).toBe(NaN); + expect(cleanParameter([])).toBe(NaN); + }); + }); + + describe('type consistency', () => { + test('should return correct types based on input', () => { + // Numbers should return numbers + expect(typeof cleanParameter(42)).toBe('number'); + + // Boolean strings should return booleans + expect(typeof cleanParameter('true')).toBe('boolean'); + + // Invalid inputs should return numbers (NaN) + expect(typeof cleanParameter('abc')).toBe('number'); + }); + + test('should handle NaN consistently', () => { + const result = cleanParameter('invalid'); + expect(Number.isNaN(result)).toBe(true); + expect(typeof result).toBe('number'); + }); + }); + + describe('parameter validation use cases', () => { + test('should handle window IDs correctly', () => { + // Typical window ID scenarios + expect(cleanParameter('1234567890')).toBe(1234567890); + }); + + test('should handle edge cases from Chrome extension context', () => { + // Chrome might pass these edge cases + expect(cleanParameter('-1')).toBe(-1); // WINDOW_ID_NONE + }); + }); +}); diff --git a/tests/cleanUrl.test.js b/tests/cleanUrl.test.js new file mode 100644 index 0000000..be48422 --- /dev/null +++ b/tests/cleanUrl.test.js @@ -0,0 +1,129 @@ +/** + * @jest-environment node + */ + +import { cleanUrl } from '../js/background/spacesService.js'; +import { setupMinimalChromeMocks } from './helpers.js'; + +// Setup minimal Chrome mocks for testing +setupMinimalChromeMocks(); + +describe('cleanUrl', () => { + describe('basic functionality', () => { + test('should return empty string for null input', () => { + expect(cleanUrl(null)).toBe(''); + }); + + test('should return empty string for undefined input', () => { + expect(cleanUrl(undefined)).toBe(''); + }); + + test('should return empty string for empty string', () => { + expect(cleanUrl('')).toBe(''); + }); + + test('should return clean URL unchanged', () => { + const url = 'https://example.com/page'; + expect(cleanUrl(url)).toBe(url); + }); + }); + + describe('query parameter removal', () => { + test('should remove single query parameter', () => { + const url = 'https://example.com/page?param=value'; + expect(cleanUrl(url)).toBe('https://example.com/page'); + }); + + test('should remove multiple query parameters', () => { + const url = 'https://example.com/page?param=value&other=test&third=123'; + expect(cleanUrl(url)).toBe('https://example.com/page'); + }); + }); + + describe('hash fragment removal', () => { + test('should remove hash fragments', () => { + const url = 'https://example.com/page#section'; + expect(cleanUrl(url)).toBe('https://example.com/page'); + }); + + test('should remove complex hash fragments', () => { + const url = 'https://example.com/page#section-with-dashes_and_underscores'; + expect(cleanUrl(url)).toBe('https://example.com/page'); + }); + }); + + describe('combined query and hash removal', () => { + test('should remove both query parameters and hash fragments', () => { + const url = 'https://example.com/page?param=value#section'; + expect(cleanUrl(url)).toBe('https://example.com/page'); + }); + + test('should handle complex combinations', () => { + const url = 'https://example.com/page?a=1&b=2&c=3#complex-hash'; + expect(cleanUrl(url)).toBe('https://example.com/page'); + }); + }); + + describe('extension URL filtering', () => { + test('should return empty string for extension URLs', () => { + const url = `chrome-extension://${chrome.runtime.id}/popup.html`; + expect(cleanUrl(url)).toBe(''); + }); + + test('should return empty string for extension URLs with query params', () => { + const url = `chrome-extension://${chrome.runtime.id}/popup.html?param=value`; + expect(cleanUrl(url)).toBe(''); + }); + }); + + describe('new tab page filtering', () => { + test('should return empty string for new tab pages', () => { + const url = 'chrome:// newtab/'; + expect(cleanUrl(url)).toBe(''); + }); + }); + + describe('The Great Suspender support', () => { + test('should extract URI from Great Suspender URLs', () => { + const suspendedUrl = 'chrome-extension://klbibkeccnjlkjkiokjodocebajanakg/suspended.html#ttl=test&pos=0&uri=https://example.com/page'; + expect(cleanUrl(suspendedUrl)).toBe('https://example.com/page'); + }); + + test('should extract URI with query params from Great Suspender URLs', () => { + const suspendedUrl = 'chrome-extension://klbibkeccnjlkjkiokjodocebajanakg/suspended.html#ttl=test&pos=0&uri=https://example.com/page?param=value'; + expect(cleanUrl(suspendedUrl)).toBe('https://example.com/page'); + }); + + test('should handle malformed Great Suspender URLs gracefully', () => { + const malformedUrl = 'chrome-extension://klbibkeccnjlkjkiokjodocebajanakg/suspended.html#ttl=test&pos=0'; + expect(cleanUrl(malformedUrl)).toBe('chrome-extension://klbibkeccnjlkjkiokjodocebajanakg/suspended.html'); + }); + }); + + describe('edge cases', () => { + test('should handle URLs with only hash', () => { + const url = 'https://example.com/#anchor'; + expect(cleanUrl(url)).toBe('https://example.com/'); + }); + + test('should handle URLs with only query', () => { + const url = 'https://example.com/?query=test'; + expect(cleanUrl(url)).toBe('https://example.com/'); + }); + + test('should handle URLs with trailing slash', () => { + const url = 'https://example.com/'; + expect(cleanUrl(url)).toBe('https://example.com/'); + }); + + test('should handle localhost URLs', () => { + const url = 'http://localhost:3000/page?debug=true#section'; + expect(cleanUrl(url)).toBe('http://localhost:3000/page'); + }); + + test('should handle file URLs', () => { + const url = 'file:///path/to/file.html?param=value'; + expect(cleanUrl(url)).toBe('file:///path/to/file.html'); + }); + }); +}); diff --git a/tests/createSessionSync.test.js b/tests/createSessionSync.test.js new file mode 100644 index 0000000..33f9237 --- /dev/null +++ b/tests/createSessionSync.test.js @@ -0,0 +1,170 @@ +import { spacesService } from '../js/background/spacesService.js'; +import { dbService } from '../js/background/dbService.js'; +import { mockConsole, mockDbCreate } from './helpers.js'; + +describe('_createSessionSync - database creation with memory synchronization', () => { + let originalCreateSession; + const TEMP_SESSION = { id: false, windowId: 100, name: 'Test Session', tabs: [{ url: 'https://example.com' }], history: [] }; + const SAVED_SESSION = { id: 123, windowId: 100, name: 'Test Session', tabs: [{ url: 'https://example.com' }], history: [] }; + + beforeEach(() => { + // Reset sessions array and initialization state + spacesService.sessions = []; + spacesService.initialized = true; + + // Store original method + originalCreateSession = dbService.createSession; + }); + + afterEach(() => { + // Restore original method + if (originalCreateSession) { + dbService.createSession = originalCreateSession; + } + }); + + test('successfully creates session and updates memory cache in place', async () => { + const temporarySession = structuredClone(TEMP_SESSION); + const savedSession = { ...SAVED_SESSION, createdAt: new Date(), lastAccess: new Date() }; + + // Add session to memory first (simulating how saveNewSession works) + spacesService.sessions.push(temporarySession); + + // Mock successful database creation + let createSessionCalled = false; + let createSessionArg = null; + mockDbCreate(async (session) => { + createSessionCalled = true; + createSessionArg = session; + return savedSession; + }); + + const result = await spacesService._createSessionSync(temporarySession); + + expect(createSessionCalled).toBe(true); + expect(createSessionArg).toBe(temporarySession); + expect(result).toBe(spacesService.sessions[0]); // Returns the updated in-memory object + expect(result.id).toBe(123); // ID was assigned + expect(result.createdAt).toBeDefined(); // Database properties merged + expect(spacesService.sessions).toHaveLength(1); + expect(spacesService.sessions).toContain(temporarySession); + + // Verify the original object was updated in place (reference preserved) + expect(spacesService.sessions).toContain(temporarySession); + expect(temporarySession.id).toBe(123); // Original object was updated + }); + + test('handles database creation failure gracefully', async () => { + const temporarySession = structuredClone(TEMP_SESSION); + + spacesService.sessions.push(temporarySession); + + // Mock database failure + mockDbCreate(null); + + const result = await spacesService._createSessionSync(temporarySession); + + expect(result).toBeNull(); + + // Memory cache should remain unchanged on failure + expect(spacesService.sessions).toHaveLength(1); + expect(spacesService.sessions).toContain(temporarySession); + expect(spacesService.sessions[0].id).toBe(false); // Still temporary + }); + + test('handles database creation exception gracefully', async () => { + const temporarySession = structuredClone(TEMP_SESSION); + + spacesService.sessions.push(temporarySession); + + // Mock database exception + const dbError = new Error('Database connection failed'); + mockDbCreate(async () => { + throw dbError; + }); + + // Spy on console.error to verify error handling + const errorSpy = mockConsole('error'); + + const result = await spacesService._createSessionSync(temporarySession); + + expect(result).toBeNull(); + expect(errorSpy.called).toBe(true); + expect(errorSpy.args[0]).toBe('Error creating session with sync:'); + expect(errorSpy.args[1]).toBe(dbError); + + // Memory cache should remain unchanged on exception + expect(spacesService.sessions).toHaveLength(1); + expect(spacesService.sessions).toContain(temporarySession); + expect(spacesService.sessions[0].id).toBe(false); + + errorSpy.restore(); + }); + + test('handles session not found in memory cache', async () => { + const temporarySession = structuredClone(TEMP_SESSION); + const savedSession = structuredClone(SAVED_SESSION); + + // Don't add session to memory cache (unusual edge case) + + // Mock successful database creation + mockDbCreate(savedSession); + + // Spy on console.warn to verify warning + const warnSpy = mockConsole('warn'); + + const result = await spacesService._createSessionSync(temporarySession); + + expect(result).toBe(savedSession); // Returns database result directly + expect(warnSpy.called).toBe(true); + expect(warnSpy.args[0]).toBe('Session not found in memory cache during create sync'); + expect(spacesService.sessions).toHaveLength(0); + expect(spacesService.sessions).toEqual([]); // Cache unchanged and empty + + warnSpy.restore(); + }); + + test('preserves object references for UI stability', async () => { + const temporarySession = structuredClone(TEMP_SESSION); + const savedSession = { ...SAVED_SESSION, createdAt: new Date() }; + + spacesService.sessions.push(temporarySession); + + // Store reference to original object + const originalRef = temporarySession; + const originalArrayRef = spacesService.sessions[0]; + + mockDbCreate(savedSession); + + const result = await spacesService._createSessionSync(temporarySession); + + // Verify that references are preserved (critical for UI) + expect(result).toBe(originalRef); + expect(spacesService.sessions).toContain(originalRef); + expect(spacesService.sessions).toContain(originalArrayRef); + + // But properties were updated + expect(originalRef.id).toBe(123); + expect(originalRef.createdAt).toBeDefined(); + }); + + test('handles multiple sessions in memory cache correctly', async () => { + const session1 = { id: false, windowId: 100, name: 'Session 1', tabs: [] }; + const session2 = { id: false, windowId: 200, name: 'Session 2', tabs: [] }; + const targetSession = { id: false, windowId: 300, name: 'Target', tabs: [] }; + + const savedSession = { id: 456, windowId: 300, name: 'Target', tabs: [] }; + + // Add multiple sessions + spacesService.sessions.push(session1, session2, targetSession); + + mockDbCreate(savedSession); + + const result = await spacesService._createSessionSync(targetSession); + + expect(result).toBe(targetSession); // Correct session updated + expect(spacesService.sessions).toHaveLength(3); + expect(spacesService.sessions).toEqual([session1, session2, targetSession]); // All sessions in correct order + expect(targetSession.id).toBe(456); // Properly updated + }); +}); diff --git a/tests/filterInternalWindows.test.js b/tests/filterInternalWindows.test.js new file mode 100644 index 0000000..460de20 --- /dev/null +++ b/tests/filterInternalWindows.test.js @@ -0,0 +1,147 @@ +/** + * @jest-environment node + */ + +import { filterInternalWindows } from '../js/background/spacesService.js'; +import { setupMinimalChromeMocks } from './helpers.js'; + +// Setup minimal Chrome mocks for testing +setupMinimalChromeMocks(); + +describe('filterInternalWindows', () => { + describe('normal windows (should not be filtered)', () => { + test('should not filter normal windows with regular websites', () => { + // Single tab + const tabWindow = { + tabs: [{ url: 'https://example.com' }], + type: 'normal' + }; + expect(filterInternalWindows(tabWindow)).toBe(false); + + // Multiple tabs + tabWindow.tabs.push({ url: 'https://example.com' }); + expect(filterInternalWindows(tabWindow)).toBe(false); + }); + + test('should not filter window with extension URL among other tabs', () => { + const window = { + tabs: [ + { url: 'https://example.com' }, + { url: `chrome-extension://${chrome.runtime.id}/spaces.html` } + ], + type: 'normal' + }; + expect(filterInternalWindows(window)).toBe(false); + }); + }); + + describe('internal extension windows (should be filtered)', () => { + test('should filter window with single tab containing extension URL', () => { + const window = { + tabs: [{ url: `chrome-extension://${chrome.runtime.id}/spaces.html` }], + type: 'normal' + }; + expect(filterInternalWindows(window)).toBe(true); + }); + + test('should filter window with single tab containing extension popup', () => { + const window = { + tabs: [{ url: `chrome-extension://${chrome.runtime.id}/popup.html` }], + type: 'normal' + }; + expect(filterInternalWindows(window)).toBe(true); + }); + + test('should filter window with single tab containing any extension page', () => { + const window = { + tabs: [{ url: `chrome-extension://${chrome.runtime.id}/any-page.html?param=value` }], + type: 'normal' + }; + expect(filterInternalWindows(window)).toBe(true); + }); + }); + + describe('popup and panel windows (should be filtered)', () => { + test('should filter popup window type', () => { + const window = { + tabs: [{ url: 'https://example.com' }], + type: 'popup' + }; + expect(filterInternalWindows(window)).toBe(true); + }); + + test('should filter panel window type', () => { + const window = { + tabs: [{ url: 'https://example.com' }], + type: 'panel' + }; + expect(filterInternalWindows(window)).toBe(true); + }); + + test('should filter popup window even with multiple tabs', () => { + const window = { + tabs: [ + { url: 'https://example.com' }, + { url: 'https://google.com' } + ], + type: 'popup' + }; + expect(filterInternalWindows(window)).toBe(true); + }); + }); + + describe('edge cases', () => { + test('should not filter window with empty tabs array', () => { + const window = { + tabs: [], + type: 'normal' + }; + expect(filterInternalWindows(window)).toBe(false); + }); + + test('should handle different extension IDs correctly', () => { + const window = { + tabs: [{ url: 'chrome-extension://different-extension-id/page.html' }], + type: 'normal' + }; + expect(filterInternalWindows(window)).toBe(false); + }); + + test('should handle partial matches in URL (possible bug)', () => { + // NOTE: This tests current behavior but may be unintended. + // The function uses indexOf() which matches extension ID anywhere in the URL, + // not just in proper chrome-extension:// URLs + const window = { + tabs: [{ url: `https://example.com/page-${chrome.runtime.id}` }], + type: 'normal' + }; + expect(filterInternalWindows(window)).toBe(true); + }); + + test('should handle undefined window type', () => { + const window = { + tabs: [{ url: 'https://example.com' }] + // type is undefined + }; + expect(filterInternalWindows(window)).toBe(false); + }); + }); + + describe('chrome-specific window types', () => { + test('should not filter app window type', () => { + const window = { + tabs: [{ url: 'https://example.com' }], + type: 'app' + }; + expect(filterInternalWindows(window)).toBe(false); + }); + + test('should not filter devtools window type', () => { + const window = { + tabs: [{ url: 'devtools://devtools/bundled/inspector.html' }], + type: 'devtools' + }; + expect(filterInternalWindows(window)).toBe(false); + }); + }); +}); diff --git a/tests/generateSessionHash.test.js b/tests/generateSessionHash.test.js new file mode 100644 index 0000000..b58e4db --- /dev/null +++ b/tests/generateSessionHash.test.js @@ -0,0 +1,204 @@ +/** + * @jest-environment node + */ + +import { generateSessionHash } from '../js/background/spacesService.js'; +import { setupMinimalChromeMocks } from './helpers.js'; + +// Setup minimal Chrome mocks for testing +setupMinimalChromeMocks(); + +describe('generateSessionHash', () => { + describe('deterministic behavior', () => { + test('should return same hash for identical input', () => { + const tabs = [ + { url: 'https://example.com' }, + { url: 'https://google.com' } + ]; + + const hash1 = generateSessionHash(tabs); + const hash2 = generateSessionHash(tabs); + + expect(hash1).toBe(hash2); + expect(typeof hash1).toBe('number'); + expect(hash1).toBeGreaterThan(0); + }); + + test('should return same hash when called multiple times with same data', () => { + const tabs = [{ url: 'https://example.com/page' }]; + + const hashes = []; + for (let i = 0; i < 5; i++) { + hashes.push(generateSessionHash(tabs)); + } + + // All hashes should be identical + expect(new Set(hashes).size).toBe(1); + }); + }); + + describe('hash uniqueness', () => { + test('should return different hashes for different tab sets', () => { + const tabs1 = [{ url: 'https://example.com' }]; + const tabs2 = [{ url: 'https://google.com' }]; + + const hash1 = generateSessionHash(tabs1); + const hash2 = generateSessionHash(tabs2); + + expect(hash1).not.toBe(hash2); + }); + + test('should return different hashes for different number of tabs', () => { + const tabs1 = [{ url: 'https://example.com' }]; + const tabs2 = [ + { url: 'https://example.com' }, + { url: 'https://google.com' } + ]; + + expect(generateSessionHash(tabs1)).not.toBe(generateSessionHash(tabs2)); + }); + + test('should return different hashes for same tabs in different order', () => { + const tabs1 = [ + { url: 'https://example.com' }, + { url: 'https://google.com' } + ]; + const tabs2 = [ + { url: 'https://google.com' }, + { url: 'https://example.com' } + ]; + + expect(generateSessionHash(tabs1)).not.toBe(generateSessionHash(tabs2)); + }); + }); + + describe('edge cases', () => { + test('should handle empty tabs array', () => { + const hash = generateSessionHash([]); + expect(hash).toBe(0); + expect(typeof hash).toBe('number'); + }); + + test('should handle tabs with no url property', () => { + const tabs = [ + { title: 'Some tab without URL' }, + { url: undefined }, + { url: null } + ]; + + // Should not crash and should return 0 since no valid URLs + expect(generateSessionHash(tabs)).toBe(0); + }); + + test('should handle tabs with empty URL strings', () => { + const tabs = [ + { url: '' }, + { url: ' ' }, + { url: 'https://example.com' } + ]; + + const hash = generateSessionHash(tabs); + expect(typeof hash).toBe('number'); + expect(hash).toBeGreaterThan(0); + }); + }); + + describe('integration with cleanUrl', () => { + test('should use cleanUrl for processing URLs', () => { + const tabs1 = [{ url: 'https://example.com?param=value#hash' }]; + const tabs2 = [{ url: 'https://example.com' }]; + + // Should be same hash because cleanUrl removes query and hash + expect(generateSessionHash(tabs1)).toBe(generateSessionHash(tabs2)); + }); + + test('should handle tabs with URLs that get filtered out by cleanUrl', () => { + const tabs = [ + { url: 'chrome-extension://test-extension-id-12345/popup.html' }, + { url: 'chrome:// newtab/' } + ]; + + // All URLs get filtered by cleanUrl, so hash should be 0 + expect(generateSessionHash(tabs)).toBe(0); + }); + + test('should handle mixed filtered and valid URLs', () => { + const tabs1 = [ + { url: 'https://example.com' }, + { url: 'chrome-extension://test-extension-id-12345/popup.html' }, + { url: 'https://google.com' } + ]; + + const tabs2 = [ + { url: 'https://example.com' }, + { url: 'https://google.com' } + ]; + + // Should be same hash since filtered URLs are ignored + expect(generateSessionHash(tabs1)).toBe(generateSessionHash(tabs2)); + }); + + test('should handle Great Suspender URLs correctly', () => { + const tabs1 = [{ + url: 'chrome-extension://klbibkeccnjlkjkiokjodocebajanakg/suspended.html#ttl=test&pos=0&uri=https://example.com' + }]; + const tabs2 = [{ url: 'https://example.com' }]; + + // Should be same hash because cleanUrl extracts the real URL from Great Suspender + expect(generateSessionHash(tabs1)).toBe(generateSessionHash(tabs2)); + }); + }); + + describe('hash properties', () => { + test('should always return positive integers', () => { + const testCases = [ + [{ url: 'https://example.com' }], + [{ url: 'https://google.com' }, { url: 'https://github.com' }], + [{ url: 'http://localhost:3000' }], + [{ url: 'https://very-long-domain-name-that-might-cause-issues.com/with/long/path/segments' }] + ]; + + testCases.forEach(tabs => { + const hash = generateSessionHash(tabs); + expect(hash).toBeGreaterThan(0); + expect(Number.isInteger(hash)).toBe(true); + }); + }); + + test('should handle unicode characters in URLs', () => { + const tabs = [{ url: 'https://example.com/café/naïve' }]; + const hash = generateSessionHash(tabs); + + expect(typeof hash).toBe('number'); + expect(hash).toBeGreaterThan(0); + expect(Number.isInteger(hash)).toBe(true); + }); + + test('should be consistent across different execution contexts', () => { + // Test with a variety of URLs to ensure algorithm stability + const tabs = [ + { url: 'https://example.com' }, + { url: 'https://www.google.com/search?q=test' }, + { url: 'https://github.com/user/repo/issues/123' } + ]; + + const hash1 = generateSessionHash(tabs); + const hash2 = generateSessionHash(JSON.parse(JSON.stringify(tabs))); // Deep copy + + expect(hash1).toBe(hash2); + }); + }); + + describe('known values for regression testing', () => { + test('should generate expected hash for simple known input', () => { + // This test helps catch unintended changes to the algorithm + const tabs = [{ url: 'https://example.com' }]; + const hash = generateSessionHash(tabs); + + // Regression test - this specific input should always produce the same hash + expect(hash).toBe(632849614); + expect(typeof hash).toBe('number'); + expect(hash).toBeGreaterThan(0); + }); + }); +}); diff --git a/tests/getDefaultSpaceTitle.test.js b/tests/getDefaultSpaceTitle.test.js new file mode 100644 index 0000000..efc2b4e --- /dev/null +++ b/tests/getDefaultSpaceTitle.test.js @@ -0,0 +1,33 @@ +import { getDefaultSpaceTitle } from '../js/spacesRenderer.js'; + +describe('getDefaultSpaceTitle', () => { + test('should return an empty string if there are no tabs', () => { + const space = { tabs: [] }; + expect(getDefaultSpaceTitle(space)).toBe(''); + }); + + test('should return the title of the single tab', () => { + const space = { tabs: [{ title: 'Test Tab' }] }; + expect(getDefaultSpaceTitle(space)).toBe('[Test Tab]'); + }); + + test('should return the title of the first tab and a count of the others', () => { + const space = { tabs: [{ title: 'Test Tab' }, { title: 'Another Tab' }] }; + expect(getDefaultSpaceTitle(space)).toBe('[Test Tab] +1 more'); + }); + + test('should truncate long titles', () => { + const space = { + tabs: [ + { title: 'This is a very long tab title that should be truncated' }, + { title: 'Another Tab' } + ] + }; + expect(getDefaultSpaceTitle(space)).toBe('[This is a very long t…] +1 more'); + }); + + test('should escape HTML in the title', () => { + const space = { tabs: [{ title: '' }] }; + expect(getDefaultSpaceTitle(space)).toBe('[<script>alert("xss")</script>]'); + }); +}); diff --git a/tests/getHashVariable.test.js b/tests/getHashVariable.test.js new file mode 100644 index 0000000..6536c91 --- /dev/null +++ b/tests/getHashVariable.test.js @@ -0,0 +1,163 @@ +/** + * @jest-environment node + */ + +import { getHashVariable } from '../js/common.js'; + +describe('getHashVariable', () => { + describe('basic functionality', () => { + test('should return false for invalid inputs', () => { + expect(getHashVariable('key', null)).toBe(false); + expect(getHashVariable('key', undefined)).toBe(false); + expect(getHashVariable('key', '')).toBe(false); + }); + + test('should return false for URL without hash', () => { + expect(getHashVariable('key', 'https://example.com')).toBe(false); + }); + + test('should return false for URL with empty hash', () => { + expect(getHashVariable('key', 'https://example.com#')).toBe(false); + }); + }); + + describe('single key-value pairs', () => { + test('should extract single key-value pair', () => { + const url = 'https://example.com#key=value'; + expect(getHashVariable('key', url)).toBe('value'); + }); + + test('should return false for non-existent key', () => { + const url = 'https://example.com#key=value'; + expect(getHashVariable('missing', url)).toBe(false); + }); + + test('should handle numeric values', () => { + const url = 'https://example.com#id=123'; + expect(getHashVariable('id', url)).toBe('123'); + }); + + test('should return false for empty values', () => { + const url = 'https://example.com#key='; + // The regex /^(.+)=(.+)/ requires at least one character after = + expect(getHashVariable('key', url)).toBe(false); + }); + }); + + describe('multiple key-value pairs', () => { + test('should extract keys from any position in multiple pairs', () => { + const url = 'https://example.com#first=value1&second=value2&third=value3'; + expect(getHashVariable('first', url)).toBe('value1'); // first position + expect(getHashVariable('second', url)).toBe('value2'); // middle position + expect(getHashVariable('third', url)).toBe('value3'); // last position + }); + + test('should return false for non-existent key in multiple pairs', () => { + const url = 'https://example.com#first=value1&second=value2&third=value3'; + expect(getHashVariable('fourth', url)).toBe(false); + }); + }); + + describe('special characters and encoding', () => { + test('should handle values with special characters', () => { + const url = 'https://example.com#message=hello%20world'; + expect(getHashVariable('message', url)).toBe('hello%20world'); + }); + + test('should handle keys with special characters', () => { + const url = 'https://example.com#special-key=value'; + expect(getHashVariable('special-key', url)).toBe('value'); + }); + + test('should return false for values with equals signs', () => { + // The regex /^(.+)=(.+)/ doesn't handle multiple = signs properly + const url = 'https://example.com#equation=x=y'; + expect(getHashVariable('equation', url)).toBe(false); + }); + + test('should handle values with ampersands in query parameters before hash', () => { + const url = 'https://example.com?param1=val1¶m2=val2#key=value'; + expect(getHashVariable('key', url)).toBe('value'); + }); + }); + + describe('malformed hash fragments', () => { + test('should ignore key-value pairs without equals sign', () => { + const url = 'https://example.com#validkey=validvalue&invalidpair&anotherkey=anothervalue'; + expect(getHashVariable('validkey', url)).toBe('validvalue'); + expect(getHashVariable('anotherkey', url)).toBe('anothervalue'); + expect(getHashVariable('invalidpair', url)).toBe(false); + }); + + test('should ignore empty key-value pairs', () => { + const url = 'https://example.com#key=value&&anotherkey=anothervalue'; + expect(getHashVariable('key', url)).toBe('value'); + expect(getHashVariable('anotherkey', url)).toBe('anothervalue'); + }); + + test('should handle hash with only ampersands', () => { + const url = 'https://example.com#&&&'; + expect(getHashVariable('key', url)).toBe(false); + }); + }); + + describe('edge cases', () => { + test('should include everything after first hash including additional hash symbols', () => { + const url = 'https://example.com#key=value#extra'; + // The regex extracts everything after the first # symbol + expect(getHashVariable('key', url)).toBe('value#extra'); + }); + + test('should be case-sensitive for keys', () => { + const url = 'https://example.com#Key=value'; + expect(getHashVariable('key', url)).toBe(false); + expect(getHashVariable('Key', url)).toBe('value'); + }); + + test('should handle duplicate keys (last one wins behavior)', () => { + const url = 'https://example.com#key=first&key=second'; + // Based on the implementation, the last key should overwrite the first + expect(getHashVariable('key', url)).toBe('second'); + }); + + test('should return false for hash at the beginning of string', () => { + const url = '#key=value'; + // The regex /^[^#]+#+(.*)/ expects at least one non-# character before # + expect(getHashVariable('key', url)).toBe(false); + }); + + test('should handle very long values', () => { + const longValue = 'a'.repeat(1000); + const url = `https://example.com#data=${longValue}`; + expect(getHashVariable('data', url)).toBe(longValue); + }); + }); + + describe('regex behavior documentation', () => { + test('should extract hash correctly with complex URLs', () => { + const complexUrl = 'https://user:pass@example.com:8080/path?query=value&other=test#key=hashvalue&second=pair'; + expect(getHashVariable('key', complexUrl)).toBe('hashvalue'); + expect(getHashVariable('second', complexUrl)).toBe('pair'); + }); + }); + + describe('real-world URL scenarios', () => { + test('should handle Chrome extension URLs', () => { + const url = 'chrome-extension://abcdef123456/spaces.html#sessionId=123&editMode=true'; + expect(getHashVariable('sessionId', url)).toBe('123'); + expect(getHashVariable('editMode', url)).toBe('true'); + }); + + test('should handle file URLs', () => { + const url = 'file:///path/to/file.html#section=intro&version=1.0'; + expect(getHashVariable('section', url)).toBe('intro'); + expect(getHashVariable('version', url)).toBe('1.0'); + }); + + test('should handle URLs with ports', () => { + const url = 'http://localhost:3000/app#tab=settings&debug=true'; + expect(getHashVariable('tab', url)).toBe('settings'); + expect(getHashVariable('debug', url)).toBe('true'); + }); + }); +}); diff --git a/tests/getSessionByWindowId.test.js b/tests/getSessionByWindowId.test.js new file mode 100644 index 0000000..32ec3bc --- /dev/null +++ b/tests/getSessionByWindowId.test.js @@ -0,0 +1,60 @@ +import { spacesService } from '../js/background/spacesService.js'; +import { dbService } from '../js/background/dbService.js'; + +describe('getSessionByWindowId - session retrieval logic', () => { + let originalFetchSessionByWindowId; + + beforeEach(() => { + // Reset sessions array + spacesService.sessions = []; + spacesService.initialized = true; + + // Mock database method to prevent indexedDB errors in tests + originalFetchSessionByWindowId = dbService.fetchSessionByWindowId; + dbService.fetchSessionByWindowId = async () => null; + }); + + afterEach(() => { + // Restore original method + if (originalFetchSessionByWindowId) { + dbService.fetchSessionByWindowId = originalFetchSessionByWindowId; + } + }); + + test('retrieves sessions by windowId and distinguishes types', async () => { + const tempSession = { + id: false, // temporary + windowId: 100, + name: false, + tabs: [{ url: 'https://temp.com' }] + }; + + const savedSession = { + id: 123, // saved + windowId: 200, + name: 'Saved Session', + tabs: [{ url: 'https://saved.com' }] + }; + + spacesService.sessions.push(tempSession, savedSession); + + // Test retrieval and type identification + const foundTemp = await spacesService.getSessionByWindowId(100); + expect(foundTemp).toBe(tempSession); + expect(foundTemp.id).toBe(false); // temporary session + expect(typeof foundTemp.id).toBe('boolean'); + + const foundSaved = await spacesService.getSessionByWindowId(200); + expect(foundSaved).toBe(savedSession); + expect(foundSaved.id).toBe(123); // saved session + expect(typeof foundSaved.id).toBe('number'); + }); + + test('returns null when no session exists for windowId', async () => { + // No sessions exist + expect(spacesService.sessions).toHaveLength(0); + + const noSession = await spacesService.getSessionByWindowId(999); + expect(noSession).toBe(null); + }); +}); diff --git a/tests/getTabDetailsString.test.js b/tests/getTabDetailsString.test.js new file mode 100644 index 0000000..b993c49 --- /dev/null +++ b/tests/getTabDetailsString.test.js @@ -0,0 +1,23 @@ +import { getTabDetailsString } from '../js/spacesRenderer.js'; + +describe('getTabDetailsString', () => { + test('should return an empty string if the space is open', () => { + const space = { windowId: 123, tabs: [{ title: 'Test Tab' }] }; + expect(getTabDetailsString(space)).toBe(''); + }); + + test('should return (0 tabs) if the space is not open and has no tabs', () => { + const space = { windowId: false, tabs: [] }; + expect(getTabDetailsString(space)).toBe('(0 tabs)'); + }); + + test('should return (1 tab) if the space is not open and has one tab', () => { + const space = { windowId: false, tabs: [{ title: 'Test Tab' }] }; + expect(getTabDetailsString(space)).toBe('(1 tab)'); + }); + + test('should return (n tabs) if the space is not open and has multiple tabs', () => { + const space = { windowId: false, tabs: [{ title: 'Test Tab' }, { title: 'Another Tab' }] }; + expect(getTabDetailsString(space)).toBe('(2 tabs)'); + }); +}); diff --git a/tests/helpers.js b/tests/helpers.js new file mode 100644 index 0000000..b5a78a7 --- /dev/null +++ b/tests/helpers.js @@ -0,0 +1,105 @@ +/** + * Shared test helper functions + */ +import { jest } from '@jest/globals'; +import { dbService } from '../js/background/dbService.js'; + +// Re-export jest for convenience +export { jest }; + +/** + * Sets up global Chrome API mocks for testing + */ +export const setupChromeMocks = () => { + global.chrome = { + runtime: { + sendMessage: jest.fn(), + }, + windows: { + getCurrent: jest.fn(), + }, + tabs: { + query: jest.fn(), + } + }; +}; + +/** + * Sets up minimal Chrome API mocks for testing (just runtime.id) + */ +export const setupMinimalChromeMocks = () => { + global.chrome = { + runtime: { + id: 'test-extension-id-12345' + } + }; +}; + +/** + * Sets up global DOM mocks for testing + */ +export const setupDOMMocks = () => { + global.document = { + addEventListener: jest.fn(), + getElementById: jest.fn(), + querySelector: jest.fn(), + querySelectorAll: jest.fn(), + }; + + global.window = { + location: { + href: 'popup.html#', + hash: '', + reload: jest.fn(), + }, + close: jest.fn(), + }; +}; + +/** + * Sets up all common test mocks (Chrome APIs and DOM) + */ +export const setupTestMocks = () => { + setupChromeMocks(); + setupDOMMocks(); +}; + +/** + * Creates a mock for console methods (error, warn, log, etc.) + * @param {string} method - The console method to mock ('error', 'warn', 'log', etc.) + * @returns {Object} Spy object with properties: called, args, restore() + */ +export const mockConsole = (method) => { + const original = console[method]; + const spy = { called: false, args: null }; + console[method] = (...capturedArgs) => { + spy.called = true; + spy.args = capturedArgs; + }; + spy.restore = () => console[method] = original; + return spy; +}; + +/** + * Creates a mock for dbService.createSession + * @param {*} returnValue - Value to return (can be a function for custom behavior) + */ +export const mockDbCreate = (returnValue) => { + if (typeof returnValue === 'function') { + dbService.createSession = returnValue; + } else { + dbService.createSession = async () => returnValue; + } +}; + +/** + * Creates a mock for dbService.updateSession + * @param {*} returnValue - Value to return (can be a function for custom behavior) + */ +export const mockDbUpdate = (returnValue) => { + if (typeof returnValue === 'function') { + dbService.updateSession = returnValue; + } else { + dbService.updateSession = async () => returnValue; + } +}; diff --git a/tests/htmlEscaping.test.js b/tests/htmlEscaping.test.js new file mode 100644 index 0000000..acf2db4 --- /dev/null +++ b/tests/htmlEscaping.test.js @@ -0,0 +1,25 @@ +import { escapeHtml } from '../js/utils.js'; + +describe('HTML escaping bug fix', () => { + test('escapes HTML characters to prevent injection', () => { + // Core HTML characters + expect(escapeHtml(' element')).toBe('<input> element'); + expect(escapeHtml('AT&T')).toBe('AT&T'); + expect(escapeHtml('"quoted text"')).toBe('"quoted text"'); + expect(escapeHtml("'apostrophe'")).toBe(''apostrophe''); + + // Complex injection attempt + expect(escapeHtml('')) + .toBe('<script>alert("XSS")</script>'); + }); + + test('handles edge cases correctly', () => { + // Empty/null values + expect(escapeHtml('')).toBe(''); + expect(escapeHtml(null)).toBe(null); + expect(escapeHtml(undefined)).toBe(undefined); + + // Normal text unchanged + expect(escapeHtml('normal text')).toBe('normal text'); + }); +}); diff --git a/tests/normaliseTabUrl.test.js b/tests/normaliseTabUrl.test.js new file mode 100644 index 0000000..e2aca63 --- /dev/null +++ b/tests/normaliseTabUrl.test.js @@ -0,0 +1,52 @@ +/** + * @jest-environment node + */ + +import { normaliseTabUrl } from '../js/spaces.js'; + +describe('normaliseTabUrl', () => { + describe('normal URLs (should remain unchanged)', () => { + test('should return regular URLs unchanged', () => { + expect(normaliseTabUrl('https://example.com')).toBe('https://example.com'); + expect(normaliseTabUrl('https://example.com#section')).toBe('https://example.com#section'); + expect(normaliseTabUrl('chrome://settings/')).toBe('chrome://settings/'); + }); + }); + + describe('Great Suspender URLs (should be normalized)', () => { + test('should extract original URL from suspended.html with uri parameter', () => { + const suspendedUrl = 'chrome-extension://klbibkeccnjlkjkiokjodocebajanakg/suspended.html#uri=https://example.com'; + expect(normaliseTabUrl(suspendedUrl)).toBe('https://example.com'); + }); + + test('should handle complex URLs and different extension IDs', () => { + const complexUrl = 'chrome-extension://different-id/suspended.html?title=Test&uri=https://docs.example.com/api/v1/users?sort=name&page=5#results'; + expect(normaliseTabUrl(complexUrl)).toBe('https://docs.example.com/api/v1/users?sort=name&page=5#results'); + }); + }); + + describe('edge cases', () => { + test('should require both suspended.html and uri parameter', () => { + expect(normaliseTabUrl('chrome-extension://abc/suspended.html#title=Something')).toBe('chrome-extension://abc/suspended.html#title=Something'); + expect(normaliseTabUrl('https://example.com/page.html#uri=https://other.com')).toBe('https://example.com/page.html#uri=https://other.com'); + }); + + test('should require suspended.html not at beginning (indexOf > 0)', () => { + expect(normaliseTabUrl('suspended.html#uri=https://example.com')).toBe('suspended.html#uri=https://example.com'); + }); + + test('should extract from first uri parameter when multiple exist', () => { + const url = 'chrome-extension://abc/suspended.html#uri=https://first.com&other=param&uri=https://second.com'; + expect(normaliseTabUrl(url)).toBe('https://first.com&other=param&uri=https://second.com'); + }); + }); + + describe('invalid inputs', () => { + test('should handle edge case inputs', () => { + expect(normaliseTabUrl('')).toBe(''); + expect(() => normaliseTabUrl(null)).toThrow(); + expect(() => normaliseTabUrl(123)).toThrow('url.indexOf is not a function'); + expect(normaliseTabUrl([])).toEqual([]); // Arrays have indexOf + }); + }); +}); diff --git a/tests/popupClickHandlers.test.js b/tests/popupClickHandlers.test.js new file mode 100644 index 0000000..4942476 --- /dev/null +++ b/tests/popupClickHandlers.test.js @@ -0,0 +1,114 @@ +/** + * Unit tests for popup.js functionality + * Tests the popup menu item click handlers that send messages to background script + */ + +import { jest, setupTestMocks } from './helpers.js'; +import { handlePopupMenuClick } from '../js/popup.js'; + +// Setup all test mocks +setupTestMocks(); + +describe('Popup Menu Click Handlers', () => { + beforeEach(() => { + jest.clearAllMocks(); + + // Reset window location + window.location.hash = ''; + window.location.reload.mockClear(); + chrome.runtime.sendMessage.mockClear(); + }); + + test('handlePopupMenuClick with switch action sends correct message and reloads popup', async () => { + // Setup: Mock successful response from background script + const mockParams = 'action=switch&windowId=123&sessionName=TestSpace&tabId=456'; + chrome.runtime.sendMessage.mockResolvedValue(mockParams); + + // Execute the click handler with switch action + await handlePopupMenuClick('switch'); + + // Verify the message was sent with correct parameters + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + 'action': 'generatePopupParams', + 'popupAction': 'switch' + }); + + // Verify popup was reloaded with correct parameters + expect(window.location.hash).toBe(mockParams); + expect(window.location.reload).toHaveBeenCalled(); + }); + + test('handlePopupMenuClick with move action sends correct message and reloads popup', async () => { + // Setup: Mock successful response from background script + const mockParams = 'action=move&windowId=123&sessionName=TestSpace&tabId=456'; + chrome.runtime.sendMessage.mockResolvedValue(mockParams); + + // Execute the click handler with move action + await handlePopupMenuClick('move'); + + // Verify the message was sent with correct parameters + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + 'action': 'generatePopupParams', + 'popupAction': 'move' + }); + + // Verify popup was reloaded with correct parameters + expect(window.location.hash).toBe(mockParams); + expect(window.location.reload).toHaveBeenCalled(); + }); + + test('handlePopupMenuClick handles empty response gracefully', async () => { + // Setup: Mock empty response from background script + chrome.runtime.sendMessage.mockResolvedValue(''); + + // Execute the click handler + await handlePopupMenuClick('test'); + + // Verify the message was sent + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + 'action': 'generatePopupParams', + 'popupAction': 'test' + }); + + // Verify popup was NOT reloaded due to empty response + expect(window.location.hash).toBe(''); + expect(window.location.reload).not.toHaveBeenCalled(); + }); + + test('handlePopupMenuClick handles null response gracefully', async () => { + // Setup: Mock null response from background script + chrome.runtime.sendMessage.mockResolvedValue(null); + + // Execute the click handler + await handlePopupMenuClick('move'); + + // Verify the message was sent + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + 'action': 'generatePopupParams', + 'popupAction': 'move' + }); + + // Verify popup was NOT reloaded due to null response + expect(window.location.hash).toBe(''); + expect(window.location.reload).not.toHaveBeenCalled(); + }); + + test('handlePopupMenuClick handles sendMessage rejection', async () => { + // Setup: Mock sendMessage rejection + const mockError = new Error('Background script error'); + chrome.runtime.sendMessage.mockRejectedValue(mockError); + + // Execute the click handler - it should throw since there's no error handling + await expect(handlePopupMenuClick('switch')).rejects.toThrow('Background script error'); + + // Verify the message was sent + expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({ + 'action': 'generatePopupParams', + 'popupAction': 'switch' + }); + + // Verify popup was NOT reloaded due to error + expect(window.location.hash).toBe(''); + expect(window.location.reload).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/updateSessionSync.test.js b/tests/updateSessionSync.test.js new file mode 100644 index 0000000..e672d55 --- /dev/null +++ b/tests/updateSessionSync.test.js @@ -0,0 +1,262 @@ +import { spacesService } from '../js/background/spacesService.js'; +import { dbService } from '../js/background/dbService.js'; +import { mockConsole, mockDbUpdate } from './helpers.js'; + +describe('_updateSessionSync', () => { + let originalUpdateSession; + const EXISTING_SESSION = { id: 123, windowId: false, name: 'Old Name', tabs: [{ url: 'https://old.com' }], history: [] }; + const UPDATED_SESSION = { id: 123, windowId: 100, name: 'New Name', tabs: [{ url: 'https://new.com' }], history: [], lastAccess: new Date(), lastModified: new Date() }; + + beforeEach(() => { + // Reset sessions array and initialization state + spacesService.sessions = []; + spacesService.initialized = true; + + // Store original method + originalUpdateSession = dbService.updateSession; + }); + + afterEach(() => { + // Restore original method + if (originalUpdateSession) { + dbService.updateSession = originalUpdateSession; + } + }); + + test('successfully updates session and syncs memory cache in place', async () => { + const existingSession = structuredClone(EXISTING_SESSION); + const updatedSession = { ...UPDATED_SESSION, lastModified: new Date() }; + + // Add session to memory + spacesService.sessions.push(existingSession); + + // Modify the session (simulating real usage) + existingSession.windowId = 100; + existingSession.name = 'New Name'; + + // Mock successful database update + let updateSessionCalled = false; + let updateSessionArg = null; + mockDbUpdate(async (session) => { + updateSessionCalled = true; + updateSessionArg = session; + return updatedSession; + }); + + const result = await spacesService._updateSessionSync(existingSession); + + expect(updateSessionCalled).toBe(true); + expect(updateSessionArg).toBe(existingSession); + expect(result).toBe(spacesService.sessions[0]); // Returns the updated in-memory object + expect(result.windowId).toBe(100); // Property was updated + expect(result.name).toBe('New Name'); // Property was updated + expect(result.lastModified).toBeDefined(); // Database properties merged + expect(spacesService.sessions).toHaveLength(1); + + // Verify the original object was updated in place (reference preserved) + expect(spacesService.sessions[0]).toBe(existingSession); + expect(existingSession.lastModified).toBeDefined(); // Original object was updated + }); + + test('handles database update failure gracefully', async () => { + const existingSession = structuredClone(EXISTING_SESSION); + + spacesService.sessions.push(existingSession); + + // Store original values + const originalWindowId = existingSession.windowId; + const originalName = existingSession.name; + + // Mock database failure + mockDbUpdate(null); + + const result = await spacesService._updateSessionSync(existingSession); + + expect(result).toBeNull(); + + // Memory cache should remain unchanged on failure + expect(spacesService.sessions).toHaveLength(1); + expect(spacesService.sessions[0]).toBe(existingSession); + // Original properties should remain (no sync occurred) + expect(existingSession.windowId).toBe(originalWindowId); + expect(existingSession.name).toBe(originalName); + }); + + test('handles database update exception gracefully', async () => { + const existingSession = structuredClone(EXISTING_SESSION); + + spacesService.sessions.push(existingSession); + + // Mock database exception + const dbError = new Error('Database update failed'); + mockDbUpdate(async () => { + throw dbError; + }); + + // Spy on console.error to verify error handling + const errorSpy = mockConsole('error'); + + const result = await spacesService._updateSessionSync(existingSession); + + expect(result).toBeNull(); + expect(errorSpy.called).toBe(true); + expect(errorSpy.args[0]).toBe('Error updating session with sync:'); + expect(errorSpy.args[1]).toBe(dbError); + + // Memory cache should remain unchanged on exception + expect(spacesService.sessions).toHaveLength(1); + expect(spacesService.sessions[0]).toBe(existingSession); + }); + + test('handles session not found in memory cache by ID', async () => { + const sessionToUpdate = structuredClone(UPDATED_SESSION); + sessionToUpdate.id = 999; // ID not in memory cache + + const updatedSession = structuredClone(UPDATED_SESSION); + updatedSession.id = 999; + + // Add a different session to memory cache + spacesService.sessions.push(structuredClone(EXISTING_SESSION)); + + // Mock successful database update + mockDbUpdate(updatedSession); + + // Spy on console.warn to verify warning + const warnSpy = mockConsole('warn'); + + const result = await spacesService._updateSessionSync(sessionToUpdate); + + expect(result).toBe(updatedSession); // Returns database result directly + expect(warnSpy.called).toBe(true); + expect(warnSpy.args[0]).toBe('Session not found in memory cache during update sync'); + expect(spacesService.sessions).toHaveLength(1); // Cache unchanged + }); + + test('preserves object references for UI stability', async () => { + const existingSession = structuredClone(EXISTING_SESSION); + + const updatedSession = structuredClone(UPDATED_SESSION); + + spacesService.sessions.push(existingSession); + + // Store reference to original object + const originalRef = existingSession; + const originalArrayRef = spacesService.sessions[0]; + + mockDbUpdate(updatedSession); + + const result = await spacesService._updateSessionSync(existingSession); + + // Verify that references are preserved (critical for UI) + expect(result).toBe(originalRef); + expect(spacesService.sessions[0]).toBe(originalRef); + expect(spacesService.sessions[0]).toBe(originalArrayRef); + + // But properties were updated + expect(originalRef.windowId).toBe(UPDATED_SESSION.windowId); + expect(originalRef.name).toBe(UPDATED_SESSION.name); + expect(originalRef.lastAccess).toBeDefined(); + }); + + test('finds correct session by ID in array with multiple sessions', async () => { + const session1 = structuredClone(EXISTING_SESSION); + session1.id = 111; + session1.windowId = 100; + session1.name = 'Session 1'; + + const session2 = structuredClone(EXISTING_SESSION); + session2.id = 222; + session2.windowId = 200; + session2.name = 'Session 2'; + + const targetSession = structuredClone(EXISTING_SESSION); + targetSession.id = 333; + targetSession.windowId = 300; + targetSession.name = 'Target'; + + const updatedTargetSession = structuredClone(UPDATED_SESSION); + updatedTargetSession.id = 333; + updatedTargetSession.windowId = 300; + updatedTargetSession.name = 'Updated Target'; + + // Add multiple sessions + spacesService.sessions.push(session1, session2, targetSession); + + mockDbUpdate(updatedTargetSession); + + const result = await spacesService._updateSessionSync(targetSession); + + expect(result).toBe(targetSession); // Correct session updated + expect(spacesService.sessions).toHaveLength(3); + expect(spacesService.sessions[0]).toBe(session1); // Others unchanged references + expect(spacesService.sessions[1]).toBe(session2); // Others unchanged references + expect(spacesService.sessions[2]).toBe(targetSession); // Target updated reference preserved + expect(targetSession.name).toBe('Updated Target'); // Properties updated + expect(targetSession.lastModified).toBeDefined(); // New properties added + }); + + test('handles windowId association changes correctly', async () => { + const session = structuredClone(EXISTING_SESSION); + + const updatedSession = structuredClone(UPDATED_SESSION); + + spacesService.sessions.push(session); + + // Simulate matchSessionToWindow behavior + session.windowId = UPDATED_SESSION.windowId; + + mockDbUpdate(updatedSession); + + const result = await spacesService._updateSessionSync(session); + + expect(result).toBe(session); + expect(session.windowId).toBe(100); // WindowId change preserved + expect(session.lastAccess).toBeDefined(); // Database changes synced + }); + + test('handles complex object properties correctly', async () => { + const session = { + id: 123, + windowId: 100, + name: 'Test Session', + tabs: [ + { url: 'https://example.com', title: 'Example', pinned: false }, + { url: 'https://test.com', title: 'Test', pinned: true } + ], + history: [ + { url: 'https://old.com', title: 'Old Page' } + ] + }; + + const updatedSession = { + id: 123, + windowId: 100, + name: 'Test Session', + tabs: [ + { url: 'https://example.com', title: 'Updated Example', pinned: false }, + { url: 'https://test.com', title: 'Test', pinned: true }, + { url: 'https://new.com', title: 'New Page', pinned: false } + ], + history: [ + { url: 'https://old.com', title: 'Old Page' }, + { url: 'https://recent.com', title: 'Recent Page' } + ], + sessionHash: 98765, + lastAccess: new Date() + }; + + spacesService.sessions.push(session); + + mockDbUpdate(updatedSession); + + const result = await spacesService._updateSessionSync(session); + + expect(result).toBe(session); + expect(session.tabs).toHaveLength(3); // Tabs array updated + expect(session.tabs[0].title).toBe('Updated Example'); // Nested properties updated + expect(session.tabs[2].url).toBe('https://new.com'); // New items added + expect(session.history).toHaveLength(2); // History updated + expect(session.sessionHash).toBe(98765); // Computed properties synced + expect(session.lastAccess).toBeDefined(); // Timestamps synced + }); +}); diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index ed410f0..0000000 --- a/yarn.lock +++ /dev/null @@ -1,1291 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@babel/code-frame@^7.0.0": - version "7.5.5" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d" - integrity sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw== - dependencies: - "@babel/highlight" "^7.0.0" - -"@babel/highlight@^7.0.0": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.5.0.tgz#56d11312bd9248fa619591d02472be6e8cb32540" - integrity sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ== - dependencies: - chalk "^2.0.0" - esutils "^2.0.2" - js-tokens "^4.0.0" - -acorn-jsx@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.1.0.tgz#294adb71b57398b0680015f0a38c563ee1db5384" - integrity sha512-tMUqwBWfLFbJbizRmEcWSLw6HnFzfdJs2sOJEOwwtVPMoH/0Ay+E703oZz78VSXZiiDcZrQ5XKjPIUQixhmgVw== - -acorn@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.1.0.tgz#949d36f2c292535da602283586c2477c57eb2d6c" - integrity sha512-kL5CuoXA/dgxlBbVrflsflzQ3PAas7RYZB52NOm/6839iVYJgKMJ3cQJD+t2i5+qFa8h3MDpEOJiS64E8JLnSQ== - -ajv@^6.10.0, ajv@^6.10.2: - version "6.10.2" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52" - integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw== - dependencies: - fast-deep-equal "^2.0.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ansi-escapes@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.2.1.tgz#4dccdb846c3eee10f6d64dea66273eab90c37228" - integrity sha512-Cg3ymMAdN10wOk/VYfLV7KCQyv7EDirJ64500sU7n9UlmioEtDuU5Gd+hj73hXSU/ex7tHJSssmyftDdkMLO8Q== - dependencies: - type-fest "^0.5.2" - -ansi-regex@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" - integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== - -ansi-regex@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.0.tgz#388539f55179bf39339c81af30a654d69f87cb75" - integrity sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg== - -ansi-styles@^3.2.0, ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -array-includes@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.0.3.tgz#184b48f62d92d7452bb31b323165c7f8bd02266d" - integrity sha1-GEtI9i2S10UrsxsyMWXH+L0CJm0= - dependencies: - define-properties "^1.1.2" - es-abstract "^1.7.0" - -astral-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" - integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== - -balanced-match@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" - integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= - -brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -chalk@^2.0.0, chalk@^2.1.0, chalk@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chardet@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" - integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== - -cli-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" - integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== - dependencies: - restore-cursor "^3.1.0" - -cli-width@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" - integrity sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk= - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= - -confusing-browser-globals@^1.0.7: - version "1.0.9" - resolved "https://registry.yarnpkg.com/confusing-browser-globals/-/confusing-browser-globals-1.0.9.tgz#72bc13b483c0276801681871d4898516f8f54fdd" - integrity sha512-KbS1Y0jMtyPgIxjO7ZzMAuUpAKMt1SzCL9fsrKsX6b0zJPTaT0SiSPmewwVZg9UAO83HVIlEhZF84LIjZ0lmAw== - -contains-path@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/contains-path/-/contains-path-0.1.0.tgz#fe8cf184ff6670b6baef01a9d4861a5cbec4120a" - integrity sha1-/ozxhP9mcLa67wGp1IYaXL7EEgo= - -cross-spawn@^6.0.5: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== - dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - -debug@^2.6.8, debug@^2.6.9: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@^4.0.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" - integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== - dependencies: - ms "^2.1.1" - -deep-is@~0.1.3: - version "0.1.3" - resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" - integrity sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ= - -define-properties@^1.1.2, define-properties@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.3.tgz#cf88da6cbee26fe6db7094f61d870cbd84cee9f1" - integrity sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== - dependencies: - object-keys "^1.0.12" - -doctrine@1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-1.5.0.tgz#379dce730f6166f76cefa4e6707a159b02c5a6fa" - integrity sha1-N53Ocw9hZvds76TmcHoVmwLFpvo= - dependencies: - esutils "^2.0.2" - isarray "^1.0.0" - -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - -emoji-regex@^7.0.1: - version "7.0.3" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" - integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -error-ex@^1.2.0: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -es-abstract@^1.12.0, es-abstract@^1.7.0: - version "1.16.0" - resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.16.0.tgz#d3a26dc9c3283ac9750dca569586e976d9dcc06d" - integrity sha512-xdQnfykZ9JMEiasTAJZJdMWCQ1Vm00NBw79/AWi7ELfZuuPCSOMDZbT9mkOfSctVtfhb+sAAzrm+j//GjjLHLg== - dependencies: - es-to-primitive "^1.2.0" - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.0" - is-callable "^1.1.4" - is-regex "^1.0.4" - object-inspect "^1.6.0" - object-keys "^1.1.1" - string.prototype.trimleft "^2.1.0" - string.prototype.trimright "^2.1.0" - -es-to-primitive@^1.2.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" - integrity sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA== - dependencies: - is-callable "^1.1.4" - is-date-object "^1.0.1" - is-symbol "^1.0.2" - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ= - -eslint-config-airbnb-base@^14.0.0: - version "14.0.0" - resolved "https://registry.yarnpkg.com/eslint-config-airbnb-base/-/eslint-config-airbnb-base-14.0.0.tgz#8a7bcb9643d13c55df4dd7444f138bf4efa61e17" - integrity sha512-2IDHobw97upExLmsebhtfoD3NAKhV4H0CJWP3Uprd/uk+cHuWYOczPVxQ8PxLFUAw7o3Th1RAU8u1DoUpr+cMA== - dependencies: - confusing-browser-globals "^1.0.7" - object.assign "^4.1.0" - object.entries "^1.1.0" - -eslint-config-prettier@^6.5.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.5.0.tgz#aaf9a495e2a816865e541bfdbb73a65cc162b3eb" - integrity sha512-cjXp8SbO9VFGW/Z7mbTydqS9to8Z58E5aYhj3e1+Hx7lS9s6gL5ILKNpCqZAFOVYRcSkWPFYljHrEh8QFEK5EQ== - dependencies: - get-stdin "^6.0.0" - -eslint-config-standard@^14.1.0: - version "14.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-standard/-/eslint-config-standard-14.1.0.tgz#b23da2b76fe5a2eba668374f246454e7058f15d4" - integrity sha512-EF6XkrrGVbvv8hL/kYa/m6vnvmUT+K82pJJc4JJVMM6+Qgqh0pnwprSxdduDLB9p/7bIxD+YV5O0wfb8lmcPbA== - -eslint-import-resolver-node@^0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.2.tgz#58f15fb839b8d0576ca980413476aab2472db66a" - integrity sha512-sfmTqJfPSizWu4aymbPr4Iidp5yKm8yDkHp+Ir3YiTHiiDfxh69mOUsmiqW6RZ9zRXFaF64GtYmN7e+8GHBv6Q== - dependencies: - debug "^2.6.9" - resolve "^1.5.0" - -eslint-module-utils@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.4.1.tgz#7b4675875bf96b0dbf1b21977456e5bb1f5e018c" - integrity sha512-H6DOj+ejw7Tesdgbfs4jeS4YMFrT8uI8xwd1gtQqXssaR0EQ26L+2O/w6wkYFy2MymON0fTwHmXBvvfLNZVZEw== - dependencies: - debug "^2.6.8" - pkg-dir "^2.0.0" - -eslint-plugin-es@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-es/-/eslint-plugin-es-2.0.0.tgz#0f5f5da5f18aa21989feebe8a73eadefb3432976" - integrity sha512-f6fceVtg27BR02EYnBhgWLFQfK6bN4Ll0nQFrBHOlCsAyxeZkn0NHns5O0YZOPrV1B3ramd6cgFwaoFLcSkwEQ== - dependencies: - eslint-utils "^1.4.2" - regexpp "^3.0.0" - -eslint-plugin-import@^2.18.2: - version "2.18.2" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.18.2.tgz#02f1180b90b077b33d447a17a2326ceb400aceb6" - integrity sha512-5ohpsHAiUBRNaBWAF08izwUGlbrJoJJ+W9/TBwsGoR1MnlgfwMIKrFeSjWbt6moabiXW9xNvtFz+97KHRfI4HQ== - dependencies: - array-includes "^3.0.3" - contains-path "^0.1.0" - debug "^2.6.9" - doctrine "1.5.0" - eslint-import-resolver-node "^0.3.2" - eslint-module-utils "^2.4.0" - has "^1.0.3" - minimatch "^3.0.4" - object.values "^1.1.0" - read-pkg-up "^2.0.0" - resolve "^1.11.0" - -eslint-plugin-node@^10.0.0: - version "10.0.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-node/-/eslint-plugin-node-10.0.0.tgz#fd1adbc7a300cf7eb6ac55cf4b0b6fc6e577f5a6" - integrity sha512-1CSyM/QCjs6PXaT18+zuAXsjXGIGo5Rw630rSKwokSs2jrYURQc4R5JZpoanNCqwNmepg+0eZ9L7YiRUJb8jiQ== - dependencies: - eslint-plugin-es "^2.0.0" - eslint-utils "^1.4.2" - ignore "^5.1.1" - minimatch "^3.0.4" - resolve "^1.10.1" - semver "^6.1.0" - -eslint-plugin-prettier@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.1.1.tgz#507b8562410d02a03f0ddc949c616f877852f2ba" - integrity sha512-A+TZuHZ0KU0cnn56/9mfR7/KjUJ9QNVXUhwvRFSR7PGPe0zQR6PTkmyqg1AtUUEOzTqeRsUwyKFh0oVZKVCrtA== - dependencies: - prettier-linter-helpers "^1.0.0" - -eslint-plugin-promise@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.2.1.tgz#845fd8b2260ad8f82564c1222fce44ad71d9418a" - integrity sha512-VoM09vT7bfA7D+upt+FjeBO5eHIJQBUWki1aPvB+vbNiHS3+oGIJGIeyBtKQTME6UPXXy3vV07OL1tHd3ANuDw== - -eslint-plugin-standard@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-standard/-/eslint-plugin-standard-4.0.1.tgz#ff0519f7ffaff114f76d1bd7c3996eef0f6e20b4" - integrity sha512-v/KBnfyaOMPmZc/dmc6ozOdWqekGp7bBGq4jLAecEfPGmfKiWS4sA8sC0LqiV9w5qmXAtXVn4M3p1jSyhY85SQ== - -eslint-scope@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.0.0.tgz#e87c8887c73e8d1ec84f1ca591645c358bfc8fb9" - integrity sha512-oYrhJW7S0bxAFDvWqzvMPRm6pcgcnWc4QnofCAqRTRfQC0JcwenzGglTtsLyIuuWFfkqDG9vz67cnttSd53djw== - dependencies: - esrecurse "^4.1.0" - estraverse "^4.1.1" - -eslint-utils@^1.4.2, eslint-utils@^1.4.3: - version "1.4.3" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.3.tgz#74fec7c54d0776b6f67e0251040b5806564e981f" - integrity sha512-fbBN5W2xdY45KulGXmLHZ3c3FHfVYmKg0IrAKGOkT/464PQsx2UeIzfz1RmEci+KLm1bBaAzZAh8+/E+XAeZ8Q== - dependencies: - eslint-visitor-keys "^1.1.0" - -eslint-visitor-keys@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" - integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== - -eslint@^6.7.1: - version "6.7.1" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.7.1.tgz#269ccccec3ef60ab32358a44d147ac209154b919" - integrity sha512-UWzBS79pNcsDSxgxbdjkmzn/B6BhsXMfUaOHnNwyE8nD+Q6pyT96ow2MccVayUTV4yMid4qLhMiQaywctRkBLA== - dependencies: - "@babel/code-frame" "^7.0.0" - ajv "^6.10.0" - chalk "^2.1.0" - cross-spawn "^6.0.5" - debug "^4.0.1" - doctrine "^3.0.0" - eslint-scope "^5.0.0" - eslint-utils "^1.4.3" - eslint-visitor-keys "^1.1.0" - espree "^6.1.2" - esquery "^1.0.1" - esutils "^2.0.2" - file-entry-cache "^5.0.1" - functional-red-black-tree "^1.0.1" - glob-parent "^5.0.0" - globals "^12.1.0" - ignore "^4.0.6" - import-fresh "^3.0.0" - imurmurhash "^0.1.4" - inquirer "^7.0.0" - is-glob "^4.0.0" - js-yaml "^3.13.1" - json-stable-stringify-without-jsonify "^1.0.1" - levn "^0.3.0" - lodash "^4.17.14" - minimatch "^3.0.4" - mkdirp "^0.5.1" - natural-compare "^1.4.0" - optionator "^0.8.3" - progress "^2.0.0" - regexpp "^2.0.1" - semver "^6.1.2" - strip-ansi "^5.2.0" - strip-json-comments "^3.0.1" - table "^5.2.3" - text-table "^0.2.0" - v8-compile-cache "^2.0.3" - -espree@^6.1.2: - version "6.1.2" - resolved "https://registry.yarnpkg.com/espree/-/espree-6.1.2.tgz#6c272650932b4f91c3714e5e7b5f5e2ecf47262d" - integrity sha512-2iUPuuPP+yW1PZaMSDM9eyVf8D5P0Hi8h83YtZ5bPc/zHYjII5khoixIUTMO794NOY8F/ThF1Bo8ncZILarUTA== - dependencies: - acorn "^7.1.0" - acorn-jsx "^5.1.0" - eslint-visitor-keys "^1.1.0" - -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -esquery@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708" - integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA== - dependencies: - estraverse "^4.0.0" - -esrecurse@^4.1.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.1.tgz#007a3b9fdbc2b3bb87e4879ea19c92fdbd3942cf" - integrity sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ== - dependencies: - estraverse "^4.1.0" - -estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1: - version "4.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" - integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -external-editor@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" - integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== - dependencies: - chardet "^0.7.0" - iconv-lite "^0.4.24" - tmp "^0.0.33" - -fast-deep-equal@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" - integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= - -fast-diff@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" - integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== - -fast-json-stable-stringify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" - integrity sha1-1RQsDK7msRifh9OnYREGT4bIu/I= - -fast-levenshtein@~2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" - integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc= - -figures@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/figures/-/figures-3.1.0.tgz#4b198dd07d8d71530642864af2d45dd9e459c4ec" - integrity sha512-ravh8VRXqHuMvZt/d8GblBeqDMkdJMBdv/2KntFH+ra5MXkO7nxNKpzQ3n6QD/2da1kH0aWmNISdvhM7gl2gVg== - dependencies: - escape-string-regexp "^1.0.5" - -file-entry-cache@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" - integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== - dependencies: - flat-cache "^2.0.1" - -find-up@^2.0.0, find-up@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" - integrity sha1-RdG35QbHF93UgndaK3eSCjwMV6c= - dependencies: - locate-path "^2.0.0" - -flat-cache@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" - integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== - dependencies: - flatted "^2.0.0" - rimraf "2.6.3" - write "1.0.3" - -flatted@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.1.tgz#69e57caa8f0eacbc281d2e2cb458d46fdb449e08" - integrity sha512-a1hQMktqW9Nmqr5aktAux3JMNqaucxGcjtjWnZLHX7yyPCmlSV3M54nGYbqT8K+0GhF3NBgmJCc3ma+WOgX8Jg== - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -functional-red-black-tree@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" - integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= - -get-stdin@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-6.0.0.tgz#9e09bf712b360ab9225e812048f71fde9c89657b" - integrity sha512-jp4tHawyV7+fkkSKyvjuLZswblUtz+SQKzSWnBbii16BuZksJlU1wuBYXY75r+duh/llF1ur6oNwi+2ZzjKZ7g== - -glob-parent@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.0.tgz#5f4c1d1e748d30cd73ad2944b3577a81b081e8c2" - integrity sha512-qjtRgnIVmOfnKUE3NJAQEdk+lKrxfw8t5ke7SXtfMTHcjsBfOfWXCQfdb30zfDoZQ2IRSIiidmjtbHZPZ++Ihw== - dependencies: - is-glob "^4.0.1" - -glob@^7.1.3: - version "7.1.6" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" - integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - -globals@^12.1.0: - version "12.3.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-12.3.0.tgz#1e564ee5c4dded2ab098b0f88f24702a3c56be13" - integrity sha512-wAfjdLgFsPZsklLJvOBUBmzYE8/CwhEqSBEMRXA3qxIiNtyqvjYurAtIfDh6chlEPUfmTY3MnZh5Hfh4q0UlIw== - dependencies: - type-fest "^0.8.1" - -graceful-fs@^4.1.2: - version "4.2.3" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" - integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha1-tdRU3CGZriJWmfNGfloH87lVuv0= - -has-symbols@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.0.tgz#ba1a8f1af2a0fc39650f5c850367704122063b44" - integrity sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q= - -has@^1.0.1, has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -hosted-git-info@^2.1.4: - version "2.8.5" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.5.tgz#759cfcf2c4d156ade59b0b2dfabddc42a6b9c70c" - integrity sha512-kssjab8CvdXfcXMXVcvsXum4Hwdq9XGtRD3TteMEvEbq0LXyiNQr6AprqKqfeaDXze7SxWvRxdpwE6ku7ikLkg== - -iconv-lite@^0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -ignore@^4.0.6: - version "4.0.6" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" - integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== - -ignore@^5.1.1: - version "5.1.4" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.1.4.tgz#84b7b3dbe64552b6ef0eca99f6743dbec6d97adf" - integrity sha512-MzbUSahkTW1u7JpKKjY7LCARd1fU5W2rLdxlM4kdkayuCwZImjkpluF9CM1aLewYJguPDqewLam18Y6AU69A8A== - -import-fresh@^3.0.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.2.1.tgz#633ff618506e793af5ac91bf48b72677e15cbe66" - integrity sha512-6e1q1cnWP2RXD9/keSkxHScg508CdXqXWgWBaETNhyuBFz+kUZlKboh+ISK+bU++DmbHimVBrOz/zzPe0sZ3sQ== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk= - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -inquirer@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.0.tgz#9e2b032dde77da1db5db804758b8fea3a970519a" - integrity sha512-rSdC7zelHdRQFkWnhsMu2+2SO41mpv2oF2zy4tMhmiLWkcKbOAs87fWAJhVXttKVwhdZvymvnuM95EyEXg2/tQ== - dependencies: - ansi-escapes "^4.2.1" - chalk "^2.4.2" - cli-cursor "^3.1.0" - cli-width "^2.0.0" - external-editor "^3.0.3" - figures "^3.0.0" - lodash "^4.17.15" - mute-stream "0.0.8" - run-async "^2.2.0" - rxjs "^6.4.0" - string-width "^4.1.0" - strip-ansi "^5.1.0" - through "^2.3.6" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= - -is-callable@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.4.tgz#1e1adf219e1eeb684d691f9d6a05ff0d30a24d75" - integrity sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA== - -is-date-object@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.1.tgz#9aa20eb6aeebbff77fbd33e74ca01b33581d3a16" - integrity sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY= - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI= - -is-fullwidth-code-point@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" - integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-glob@^4.0.0, is-glob@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.1.tgz#7567dbe9f2f5e2467bc77ab83c4a29482407a5dc" - integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg== - dependencies: - is-extglob "^2.1.1" - -is-promise@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" - integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= - -is-regex@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.4.tgz#5517489b547091b0930e095654ced25ee97e9491" - integrity sha1-VRdIm1RwkbCTDglWVM7SXul+lJE= - dependencies: - has "^1.0.1" - -is-symbol@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.2.tgz#a055f6ae57192caee329e7a860118b497a950f38" - integrity sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw== - dependencies: - has-symbols "^1.0.0" - -isarray@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE= - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= - -js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@^3.13.1: - version "3.13.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" - integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-stable-stringify-without-jsonify@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" - integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= - -levn@^0.3.0, levn@~0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee" - integrity sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4= - dependencies: - prelude-ls "~1.1.2" - type-check "~0.3.2" - -load-json-file@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-2.0.0.tgz#7947e42149af80d696cbf797bcaabcfe1fe29ca8" - integrity sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg= - dependencies: - graceful-fs "^4.1.2" - parse-json "^2.2.0" - pify "^2.0.0" - strip-bom "^3.0.0" - -locate-path@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e" - integrity sha1-K1aLJl7slExtnA3pw9u7ygNUzY4= - dependencies: - p-locate "^2.0.0" - path-exists "^3.0.0" - -lodash@^4.17.14, lodash@^4.17.15: - version "4.17.15" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" - integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== - -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - -minimatch@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" - integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== - dependencies: - brace-expansion "^1.1.7" - -minimist@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" - integrity sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0= - -mkdirp@^0.5.1: - version "0.5.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903" - integrity sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM= - dependencies: - minimist "0.0.8" - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= - -ms@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -mute-stream@0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" - integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== - -natural-compare@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" - integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= - -nice-try@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" - integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== - -normalize-package-data@^2.3.2: - version "2.5.0" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8" - integrity sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA== - dependencies: - hosted-git-info "^2.1.4" - resolve "^1.10.0" - semver "2 || 3 || 4 || 5" - validate-npm-package-license "^3.0.1" - -object-inspect@^1.6.0: - version "1.7.0" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.7.0.tgz#f4f6bd181ad77f006b5ece60bd0b6f398ff74a67" - integrity sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw== - -object-keys@^1.0.11, object-keys@^1.0.12, object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - -object.assign@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.0.tgz#968bf1100d7956bb3ca086f006f846b3bc4008da" - integrity sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w== - dependencies: - define-properties "^1.1.2" - function-bind "^1.1.1" - has-symbols "^1.0.0" - object-keys "^1.0.11" - -object.entries@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/object.entries/-/object.entries-1.1.0.tgz#2024fc6d6ba246aee38bdb0ffd5cfbcf371b7519" - integrity sha512-l+H6EQ8qzGRxbkHOd5I/aHRhHDKoQXQ8g0BYt4uSweQU1/J6dZUOyWh9a2Vky35YCKjzmgxOzta2hH6kf9HuXA== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.12.0" - function-bind "^1.1.1" - has "^1.0.3" - -object.values@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.0.tgz#bf6810ef5da3e5325790eaaa2be213ea84624da9" - integrity sha512-8mf0nKLAoFX6VlNVdhGj31SVYpaNFtUnuoOXWyFEstsWRgU837AK+JYM0iAxwkSzGRbwn8cbFmgbyxj1j4VbXg== - dependencies: - define-properties "^1.1.3" - es-abstract "^1.12.0" - function-bind "^1.1.1" - has "^1.0.3" - -once@^1.3.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E= - dependencies: - wrappy "1" - -onetime@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.0.tgz#fff0f3c91617fe62bb50189636e99ac8a6df7be5" - integrity sha512-5NcSkPHhwTVFIQN+TUqXoS5+dlElHXdpAWu9I0HP20YOtIi+aZ0Ct82jdlILDxjLEAWwvm+qj1m6aEtsDVmm6Q== - dependencies: - mimic-fn "^2.1.0" - -optionator@^0.8.3: - version "0.8.3" - resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" - integrity sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA== - dependencies: - deep-is "~0.1.3" - fast-levenshtein "~2.0.6" - levn "~0.3.0" - prelude-ls "~1.1.2" - type-check "~0.3.2" - word-wrap "~1.2.3" - -os-tmpdir@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= - -p-limit@^1.1.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8" - integrity sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q== - dependencies: - p-try "^1.0.0" - -p-locate@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-2.0.0.tgz#20a0103b222a70c8fd39cc2e580680f3dde5ec43" - integrity sha1-IKAQOyIqcMj9OcwuWAaA893l7EM= - dependencies: - p-limit "^1.1.0" - -p-try@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3" - integrity sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M= - -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parse-json@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-2.2.0.tgz#f480f40434ef80741f8469099f8dea18f55a4dc9" - integrity sha1-9ID0BDTvgHQfhGkJn43qGPVaTck= - dependencies: - error-ex "^1.2.0" - -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha1-zg6+ql94yxiSXqfYENe1mwEP1RU= - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= - -path-key@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= - -path-parse@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" - integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== - -path-type@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-2.0.0.tgz#f012ccb8415b7096fc2daa1054c3d72389594c73" - integrity sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM= - dependencies: - pify "^2.0.0" - -pify@^2.0.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" - integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= - -pkg-dir@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-2.0.0.tgz#f6d5d1109e19d63edf428e0bd57e12777615334b" - integrity sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s= - dependencies: - find-up "^2.1.0" - -prelude-ls@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" - integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= - -prettier-linter-helpers@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz#d23d41fe1375646de2d0104d3454a3008802cf7b" - integrity sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w== - dependencies: - fast-diff "^1.1.2" - -prettier@^1.19.1: - version "1.19.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" - integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== - -progress@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" - integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== - -punycode@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" - integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== - -read-pkg-up@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-2.0.0.tgz#6b72a8048984e0c41e79510fd5e9fa99b3b549be" - integrity sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4= - dependencies: - find-up "^2.0.0" - read-pkg "^2.0.0" - -read-pkg@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-2.0.0.tgz#8ef1c0623c6a6db0dc6713c4bfac46332b2368f8" - integrity sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg= - dependencies: - load-json-file "^2.0.0" - normalize-package-data "^2.3.2" - path-type "^2.0.0" - -regexpp@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" - integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== - -regexpp@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.0.0.tgz#dd63982ee3300e67b41c1956f850aa680d9d330e" - integrity sha512-Z+hNr7RAVWxznLPuA7DIh8UNX1j9CDrUQxskw9IrBE1Dxue2lyXT+shqEIeLUjrokxIP8CMy1WkjgG3rTsd5/g== - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -resolve@^1.10.0, resolve@^1.10.1, resolve@^1.11.0, resolve@^1.5.0: - version "1.12.0" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.12.0.tgz#3fc644a35c84a48554609ff26ec52b66fa577df6" - integrity sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w== - dependencies: - path-parse "^1.0.6" - -restore-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" - integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== - dependencies: - onetime "^5.1.0" - signal-exit "^3.0.2" - -rimraf@2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" - integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== - dependencies: - glob "^7.1.3" - -run-async@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" - integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA= - dependencies: - is-promise "^2.1.0" - -rxjs@^6.4.0: - version "6.5.3" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.3.tgz#510e26317f4db91a7eb1de77d9dd9ba0a4899a3a" - integrity sha512-wuYsAYYFdWTAnAaPoKGNhfpWwKZbJW+HgAJ+mImp+Epl7BG8oNWBCTyRM8gba9k4lk8BgWdoYm21Mo/RYhhbgA== - dependencies: - tslib "^1.9.0" - -"safer-buffer@>= 2.1.2 < 3": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -"semver@2 || 3 || 4 || 5", semver@^5.5.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" - integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== - -semver@^6.1.0, semver@^6.1.2: - version "6.3.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" - integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== - -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= - dependencies: - shebang-regex "^1.0.0" - -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= - -signal-exit@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" - integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= - -slice-ansi@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" - integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== - dependencies: - ansi-styles "^3.2.0" - astral-regex "^1.0.0" - is-fullwidth-code-point "^2.0.0" - -spdx-correct@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/spdx-correct/-/spdx-correct-3.1.0.tgz#fb83e504445268f154b074e218c87c003cd31df4" - integrity sha512-lr2EZCctC2BNR7j7WzJ2FpDznxky1sjfxvvYEyzxNyb6lZXHODmEoJeFu4JupYlkfha1KZpJyoqiJ7pgA1qq8Q== - dependencies: - spdx-expression-parse "^3.0.0" - spdx-license-ids "^3.0.0" - -spdx-exceptions@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/spdx-exceptions/-/spdx-exceptions-2.2.0.tgz#2ea450aee74f2a89bfb94519c07fcd6f41322977" - integrity sha512-2XQACfElKi9SlVb1CYadKDXvoajPgBVPn/gOQLrTvHdElaVhr7ZEbqJaRnJLVNeaI4cMEAgVCeBMKF6MWRDCRA== - -spdx-expression-parse@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz#99e119b7a5da00e05491c9fa338b7904823b41d0" - integrity sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg== - dependencies: - spdx-exceptions "^2.1.0" - spdx-license-ids "^3.0.0" - -spdx-license-ids@^3.0.0: - version "3.0.5" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.5.tgz#3694b5804567a458d3c8045842a6358632f62654" - integrity sha512-J+FWzZoynJEXGphVIS+XEh3kFSjZX/1i9gFBaWQcB+/tmpe2qUsSBABpcxqxnAxFdiUFEgAX1bjYGQvIZmoz9Q== - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= - -string-width@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" - integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== - dependencies: - emoji-regex "^7.0.1" - is-fullwidth-code-point "^2.0.0" - strip-ansi "^5.1.0" - -string-width@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.0.tgz#952182c46cc7b2c313d1596e623992bd163b72b5" - integrity sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.0" - -string.prototype.trimleft@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz#6cc47f0d7eb8d62b0f3701611715a3954591d634" - integrity sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw== - dependencies: - define-properties "^1.1.3" - function-bind "^1.1.1" - -string.prototype.trimright@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz#669d164be9df9b6f7559fa8e89945b168a5a6c58" - integrity sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg== - dependencies: - define-properties "^1.1.3" - function-bind "^1.1.1" - -strip-ansi@^5.1.0, strip-ansi@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" - integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== - dependencies: - ansi-regex "^4.1.0" - -strip-ansi@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.0.tgz#0b1571dd7669ccd4f3e06e14ef1eed26225ae532" - integrity sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w== - dependencies: - ansi-regex "^5.0.0" - -strip-bom@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" - integrity sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM= - -strip-json-comments@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.0.1.tgz#85713975a91fb87bf1b305cca77395e40d2a64a7" - integrity sha512-VTyMAUfdm047mwKl+u79WIdrZxtFtn+nBxHeb844XBQ9uMNTuTHdx2hc5RiAJYqwTj3wc/xe5HLSdJSkJ+WfZw== - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -table@^5.2.3: - version "5.4.6" - resolved "https://registry.yarnpkg.com/table/-/table-5.4.6.tgz#1292d19500ce3f86053b05f0e8e7e4a3bb21079e" - integrity sha512-wmEc8m4fjnob4gt5riFRtTu/6+4rSe12TpAELNSqHMfF3IqnA+CH37USM6/YR3qRZv7e56kAEAtd6nKZaxe0Ug== - dependencies: - ajv "^6.10.2" - lodash "^4.17.14" - slice-ansi "^2.1.0" - string-width "^3.0.0" - -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= - -through@^2.3.6: - version "2.3.8" - resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" - integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= - -tmp@^0.0.33: - version "0.0.33" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" - integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== - dependencies: - os-tmpdir "~1.0.2" - -tslib@^1.9.0: - version "1.10.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" - integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== - -type-check@~0.3.2: - version "0.3.2" - resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" - integrity sha1-WITKtRLPHTVeP7eE8wgEsrUg23I= - dependencies: - prelude-ls "~1.1.2" - -type-fest@^0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.5.2.tgz#d6ef42a0356c6cd45f49485c3b6281fc148e48a2" - integrity sha512-DWkS49EQKVX//Tbupb9TFa19c7+MK1XmzkrZUR8TAktmE/DizXoaoJV6TZ/tSIPXipqNiRI6CyAe7x69Jb6RSw== - -type-fest@^0.8.1: - version "0.8.1" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" - integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== - -uri-js@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" - integrity sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ== - dependencies: - punycode "^2.1.0" - -v8-compile-cache@^2.0.3: - version "2.1.0" - resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.0.tgz#e14de37b31a6d194f5690d67efc4e7f6fc6ab30e" - integrity sha512-usZBT3PW+LOjM25wbqIlZwPeJV+3OSz3M1k1Ws8snlW39dZyYL9lOGC5FgPVHfk0jKmjiDV8Z0mIbVQPiwFs7g== - -validate-npm-package-license@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz#fc91f6b9c7ba15c857f4cb2c5defeec39d4f410a" - integrity sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew== - dependencies: - spdx-correct "^3.0.0" - spdx-expression-parse "^3.0.0" - -which@^1.2.9: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - -word-wrap@~1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c" - integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ== - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= - -write@1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3" - integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== - dependencies: - mkdirp "^0.5.1"