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