494 lines
16 KiB
JavaScript
494 lines
16 KiB
JavaScript
'use strict';
|
||
|
||
(function attachTabHarborTabSessions(globalScope) {
|
||
const urlUtils = globalScope.TabHarborTabUrlUtils || (
|
||
typeof require === 'function' ? require('./tab-url-utils.js') : {}
|
||
);
|
||
|
||
const {
|
||
getCanonicalTabUrl: sessionsGetCanonicalTabUrl,
|
||
isRestorableTabUrl: sessionsIsRestorableTabUrl,
|
||
parseSuspendedTabUrl: sessionsParseSuspendedTabUrl,
|
||
} = urlUtils;
|
||
|
||
const SAVED_TAB_SESSIONS_KEY = 'savedTabSessions';
|
||
const MANUAL_GROUP_PREFIX = '__session_group__:';
|
||
|
||
function createSessionId(now = new Date()) {
|
||
return `tab-session-${now.getTime()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
}
|
||
|
||
function normalizeString(value = '') {
|
||
return String(value || '').trim();
|
||
}
|
||
|
||
function capitalizeLabel(value = '') {
|
||
const cleanValue = normalizeString(value);
|
||
if (!cleanValue) return '';
|
||
return cleanValue.charAt(0).toUpperCase() + cleanValue.slice(1);
|
||
}
|
||
|
||
function getHostnameFromUrl(value = '') {
|
||
try {
|
||
return normalizeString(new URL(String(value || '')).hostname || '');
|
||
} catch {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function getFriendlyHostnameLabel(hostname = '') {
|
||
const cleanHostname = normalizeString(hostname).replace(/^www\./, '');
|
||
if (!cleanHostname) return '';
|
||
if (cleanHostname === 'github.com') return 'GitHub';
|
||
if (cleanHostname === 'docs.github.com') return 'Docs';
|
||
|
||
const parts = cleanHostname.split('.').filter(Boolean);
|
||
if (parts.length >= 3) {
|
||
return parts[0]
|
||
.split(/[-_]/)
|
||
.map(capitalizeLabel)
|
||
.join(' ');
|
||
}
|
||
|
||
const base = (parts[0] || '')
|
||
.replace(/\.(co\.uk|co\.jp|com|org|net|io|co|ai|dev|app|so|me|xyz|info|us|uk)$/, '');
|
||
return base
|
||
.split(/[-_]/)
|
||
.map(capitalizeLabel)
|
||
.join(' ');
|
||
}
|
||
|
||
function stripSessionTitleNoise(title = '') {
|
||
return normalizeString(title)
|
||
.replace(/^\(\d+\+?\)\s*/, '')
|
||
.replace(/\s*\([\d,]+\+?\)\s*/g, ' ')
|
||
.replace(/\s+on X:\s*/, ': ')
|
||
.replace(/\s*\/\s*X\s*$/, '')
|
||
.trim();
|
||
}
|
||
|
||
function cleanDerivedSessionTitle(title = '', hostname = '') {
|
||
const cleanTitle = stripSessionTitleNoise(title);
|
||
if (!cleanTitle) return '';
|
||
if (cleanTitle.startsWith('http://') || cleanTitle.startsWith('https://')) return '';
|
||
|
||
const domain = normalizeString(hostname).replace(/^www\./, '');
|
||
const friendly = getFriendlyHostnameLabel(domain);
|
||
const domainWithoutSuffix = domain.replace(/\.\w+$/, '');
|
||
const separators = [' - ', ' | ', ' — ', ' · ', ' – '];
|
||
|
||
for (const separator of separators) {
|
||
const separatorIndex = cleanTitle.lastIndexOf(separator);
|
||
if (separatorIndex === -1) continue;
|
||
const suffix = cleanTitle.slice(separatorIndex + separator.length).trim().toLowerCase();
|
||
if (
|
||
suffix === domain.toLowerCase() ||
|
||
suffix === friendly.toLowerCase() ||
|
||
suffix === domainWithoutSuffix.toLowerCase()
|
||
) {
|
||
const stripped = cleanTitle.slice(0, separatorIndex).trim();
|
||
if (stripped.length >= 2) return stripped;
|
||
}
|
||
}
|
||
|
||
return cleanTitle;
|
||
}
|
||
|
||
function normalizeSessionTab(input = {}) {
|
||
const canonicalUrl = sessionsGetCanonicalTabUrl ? sessionsGetCanonicalTabUrl(input.url || '') : normalizeString(input.url);
|
||
if (!canonicalUrl || !(sessionsIsRestorableTabUrl ? sessionsIsRestorableTabUrl(canonicalUrl) : true)) return null;
|
||
|
||
return {
|
||
url: canonicalUrl,
|
||
title: normalizeString(input.title) || canonicalUrl,
|
||
favIconUrl: normalizeString(input.favIconUrl),
|
||
groupKey: normalizeString(input.groupKey),
|
||
groupLabel: normalizeString(input.groupLabel),
|
||
manualGroupId: normalizeString(input.manualGroupId),
|
||
};
|
||
}
|
||
|
||
function normalizeSessionGroup(input = {}, validUrls = new Set()) {
|
||
const key = normalizeString(input.key);
|
||
if (!key) return null;
|
||
const tabUrls = Array.isArray(input.tabUrls)
|
||
? input.tabUrls
|
||
.map(url => sessionsGetCanonicalTabUrl ? sessionsGetCanonicalTabUrl(url) : normalizeString(url))
|
||
.filter(url => url && validUrls.has(url))
|
||
: [];
|
||
if (!tabUrls.length) return null;
|
||
|
||
return {
|
||
key,
|
||
label: normalizeString(input.label) || 'Group',
|
||
manualGroupId: normalizeString(input.manualGroupId),
|
||
tabUrls: [...new Set(tabUrls)],
|
||
};
|
||
}
|
||
|
||
function normalizeSavedTabSessions(input) {
|
||
if (!Array.isArray(input)) return [];
|
||
|
||
return input
|
||
.map(session => {
|
||
const tabs = Array.isArray(session?.tabs)
|
||
? session.tabs.map(normalizeSessionTab).filter(Boolean)
|
||
: [];
|
||
if (!tabs.length) return null;
|
||
|
||
const validUrls = new Set(tabs.map(tab => tab.url));
|
||
const groups = Array.isArray(session?.groups)
|
||
? session.groups.map(group => normalizeSessionGroup(group, validUrls)).filter(Boolean)
|
||
: [];
|
||
|
||
return {
|
||
id: normalizeString(session.id) || createSessionId(),
|
||
name: normalizeString(session.name) || 'Saved tabs',
|
||
savedAt: normalizeString(session.savedAt) || new Date().toISOString(),
|
||
source: normalizeString(session.source) || 'manual',
|
||
tabs,
|
||
groups,
|
||
};
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function buildSessionGroupsFromTabs(tabs = []) {
|
||
const groupMap = new Map();
|
||
|
||
for (const tab of Array.isArray(tabs) ? tabs : []) {
|
||
const key = normalizeString(tab?.groupKey);
|
||
if (!key) continue;
|
||
if (!groupMap.has(key)) {
|
||
groupMap.set(key, {
|
||
key,
|
||
label: normalizeString(tab?.groupLabel) || 'Group',
|
||
manualGroupId: normalizeString(tab?.manualGroupId),
|
||
tabUrls: [],
|
||
});
|
||
}
|
||
const group = groupMap.get(key);
|
||
if (!group.tabUrls.includes(tab.url)) group.tabUrls.push(tab.url);
|
||
}
|
||
|
||
return [...groupMap.values()];
|
||
}
|
||
|
||
function createUniqueSavedTabSessionName(baseName, sessions = [], excludeSessionId = '') {
|
||
const fallbackName = normalizeString(baseName) || 'Saved tabs';
|
||
const lowerFallback = fallbackName.toLowerCase();
|
||
const takenNames = new Set(
|
||
(Array.isArray(sessions) ? sessions : [])
|
||
.filter(session => String(session?.id || '') !== String(excludeSessionId || ''))
|
||
.map(session => normalizeString(session?.name).toLowerCase())
|
||
.filter(Boolean)
|
||
);
|
||
|
||
if (!takenNames.has(lowerFallback)) return fallbackName;
|
||
|
||
let suffix = 2;
|
||
while (takenNames.has(`${lowerFallback} ${suffix}`)) suffix += 1;
|
||
return `${fallbackName} ${suffix}`;
|
||
}
|
||
|
||
function deriveSavedTabSessionName(tab = {}) {
|
||
const hostname = getHostnameFromUrl(tab?.url || '');
|
||
const title = cleanDerivedSessionTitle(tab?.title || '', hostname);
|
||
if (title) return title;
|
||
|
||
return getFriendlyHostnameLabel(hostname) || 'Saved tabs';
|
||
}
|
||
|
||
function createSavedTabSessionFromTab({
|
||
sourceSession = {},
|
||
tab = {},
|
||
existingSessions = [],
|
||
now = new Date().toISOString(),
|
||
} = {}) {
|
||
const normalizedTab = normalizeSessionTab(tab);
|
||
if (!normalizedTab) throw new Error('Dragged tab is not restorable');
|
||
|
||
const savedAt = normalizeString(now) || new Date().toISOString();
|
||
const baseName = deriveSavedTabSessionName({
|
||
...tab,
|
||
...normalizedTab,
|
||
});
|
||
|
||
return {
|
||
id: createSessionId(new Date(savedAt)),
|
||
name: createUniqueSavedTabSessionName(baseName, existingSessions),
|
||
savedAt,
|
||
source: normalizeString(sourceSession?.source) || 'manual',
|
||
tabs: [normalizedTab],
|
||
groups: buildSessionGroupsFromTabs([normalizedTab]),
|
||
};
|
||
}
|
||
|
||
function getTabId(tab = {}) {
|
||
return tab?.id == null ? '' : String(tab.id);
|
||
}
|
||
|
||
function getTabTitle(tab = {}, canonicalUrl = '') {
|
||
const suspended = sessionsParseSuspendedTabUrl
|
||
? sessionsParseSuspendedTabUrl(tab.url || '')
|
||
: { title: '' };
|
||
return normalizeString(suspended.title) || normalizeString(tab.title) || canonicalUrl;
|
||
}
|
||
|
||
function normalizeGroupLookupEntry(entry = {}) {
|
||
if (!entry || typeof entry !== 'object') return null;
|
||
const key = normalizeString(entry.key);
|
||
if (!key) return null;
|
||
return {
|
||
key,
|
||
label: normalizeString(entry.label) || 'Group',
|
||
manualGroupId: normalizeString(entry.manualGroupId),
|
||
};
|
||
}
|
||
|
||
function buildSessionSnapshot({
|
||
tabs = [],
|
||
groupLookup = new Map(),
|
||
selectedTabIds = null,
|
||
source = 'manual',
|
||
name = '',
|
||
now = new Date().toISOString(),
|
||
} = {}) {
|
||
const selectedIds = Array.isArray(selectedTabIds)
|
||
? new Set(selectedTabIds.map(String))
|
||
: null;
|
||
const savedAt = normalizeString(now) || new Date().toISOString();
|
||
|
||
const savedTabs = [];
|
||
const groupMap = new Map();
|
||
|
||
for (const tab of Array.isArray(tabs) ? tabs : []) {
|
||
const tabId = getTabId(tab);
|
||
if (selectedIds && !selectedIds.has(tabId)) continue;
|
||
|
||
const canonicalUrl = sessionsGetCanonicalTabUrl ? sessionsGetCanonicalTabUrl(tab?.url || '') : normalizeString(tab?.url);
|
||
if (!canonicalUrl || !(sessionsIsRestorableTabUrl ? sessionsIsRestorableTabUrl(canonicalUrl) : true)) continue;
|
||
|
||
const lookupEntry = typeof groupLookup?.get === 'function'
|
||
? normalizeGroupLookupEntry(groupLookup.get(tabId) || groupLookup.get(canonicalUrl))
|
||
: null;
|
||
const savedTab = {
|
||
url: canonicalUrl,
|
||
title: getTabTitle(tab, canonicalUrl),
|
||
favIconUrl: normalizeString(tab?.favIconUrl),
|
||
groupKey: lookupEntry?.key || '',
|
||
groupLabel: lookupEntry?.label || '',
|
||
manualGroupId: lookupEntry?.manualGroupId || '',
|
||
};
|
||
|
||
savedTabs.push(savedTab);
|
||
|
||
if (lookupEntry) {
|
||
if (!groupMap.has(lookupEntry.key)) {
|
||
groupMap.set(lookupEntry.key, {
|
||
key: lookupEntry.key,
|
||
label: lookupEntry.label,
|
||
manualGroupId: lookupEntry.manualGroupId,
|
||
tabUrls: [],
|
||
});
|
||
}
|
||
groupMap.get(lookupEntry.key).tabUrls.push(canonicalUrl);
|
||
}
|
||
}
|
||
|
||
if (!savedTabs.length) {
|
||
throw new Error('No restorable tabs selected');
|
||
}
|
||
|
||
const groups = [...groupMap.values()].map(group => ({
|
||
...group,
|
||
tabUrls: [...new Set(group.tabUrls)],
|
||
}));
|
||
|
||
return {
|
||
id: createSessionId(new Date(savedAt)),
|
||
name: normalizeString(name) || `Saved tabs ${new Date(savedAt).toLocaleString()}`,
|
||
savedAt,
|
||
source: normalizeString(source) || 'manual',
|
||
tabs: savedTabs,
|
||
groups,
|
||
};
|
||
}
|
||
|
||
function createRestoredSessionGroups({
|
||
existingState = { groups: [], assignments: {} },
|
||
session = {},
|
||
restoredTabs = [],
|
||
now = new Date().toISOString(),
|
||
} = {}) {
|
||
const normalizedState = {
|
||
groups: Array.isArray(existingState?.groups) ? existingState.groups.slice() : [],
|
||
assignments: existingState?.assignments && typeof existingState.assignments === 'object'
|
||
? { ...existingState.assignments }
|
||
: {},
|
||
};
|
||
const restoredByUrl = new Map();
|
||
for (const tab of Array.isArray(restoredTabs) ? restoredTabs : []) {
|
||
const canonicalUrl = sessionsGetCanonicalTabUrl ? sessionsGetCanonicalTabUrl(tab?.url || '') : normalizeString(tab?.url);
|
||
if (!canonicalUrl || tab?.id == null) continue;
|
||
if (!restoredByUrl.has(canonicalUrl)) restoredByUrl.set(canonicalUrl, []);
|
||
restoredByUrl.get(canonicalUrl).push(String(tab.id));
|
||
}
|
||
|
||
const sessionGroups = Array.isArray(session?.groups) ? session.groups : [];
|
||
sessionGroups
|
||
.filter(group => normalizeString(group?.key).startsWith(MANUAL_GROUP_PREFIX))
|
||
.forEach((group, index) => {
|
||
const groupName = normalizeString(group.label) || 'Restored group';
|
||
const groupId = `restored-${Date.now()}-${index}-${Math.random().toString(36).slice(2, 8)}`;
|
||
normalizedState.groups.push({
|
||
id: groupId,
|
||
name: groupName,
|
||
createdAt: normalizeString(now) || new Date().toISOString(),
|
||
});
|
||
|
||
for (const url of Array.isArray(group.tabUrls) ? group.tabUrls : []) {
|
||
const tabIds = restoredByUrl.get(url) || [];
|
||
const tabId = tabIds.shift();
|
||
if (tabId) normalizedState.assignments[tabId] = groupId;
|
||
}
|
||
});
|
||
|
||
return normalizedState;
|
||
}
|
||
|
||
async function getSavedTabSessions() {
|
||
const stored = await chrome.storage.local.get(SAVED_TAB_SESSIONS_KEY);
|
||
return normalizeSavedTabSessions(stored[SAVED_TAB_SESSIONS_KEY]);
|
||
}
|
||
|
||
async function saveSavedTabSessions(sessions) {
|
||
const normalized = normalizeSavedTabSessions(sessions);
|
||
await chrome.storage.local.set({ [SAVED_TAB_SESSIONS_KEY]: normalized });
|
||
return normalized;
|
||
}
|
||
|
||
async function addSavedTabSession(session) {
|
||
const sessions = await getSavedTabSessions();
|
||
const normalized = normalizeSavedTabSessions([session])[0];
|
||
if (!normalized) throw new Error('No restorable tabs selected');
|
||
return saveSavedTabSessions([normalized, ...sessions]);
|
||
}
|
||
|
||
async function deleteSavedTabSession(sessionId) {
|
||
const targetId = normalizeString(sessionId);
|
||
const sessions = await getSavedTabSessions();
|
||
return saveSavedTabSessions(sessions.filter(session => session.id !== targetId));
|
||
}
|
||
|
||
async function renameSavedTabSession(sessionId, nextName) {
|
||
const targetId = normalizeString(sessionId);
|
||
const cleanName = normalizeString(nextName);
|
||
const sessions = await getSavedTabSessions();
|
||
return saveSavedTabSessions(sessions.map(session => {
|
||
if (session.id !== targetId) return session;
|
||
return {
|
||
...session,
|
||
name: cleanName || session.name,
|
||
};
|
||
}));
|
||
}
|
||
|
||
async function updateSavedTabSessionTabs(sessionId, nextTabs) {
|
||
const targetId = normalizeString(sessionId);
|
||
const normalizedTabs = Array.isArray(nextTabs)
|
||
? nextTabs.map(normalizeSessionTab).filter(Boolean)
|
||
: [];
|
||
const sessions = await getSavedTabSessions();
|
||
const nextSessions = [];
|
||
|
||
for (const session of sessions) {
|
||
if (session.id !== targetId) {
|
||
nextSessions.push(session);
|
||
continue;
|
||
}
|
||
|
||
if (!normalizedTabs.length) continue;
|
||
|
||
nextSessions.push({
|
||
...session,
|
||
tabs: normalizedTabs,
|
||
groups: buildSessionGroupsFromTabs(normalizedTabs),
|
||
});
|
||
}
|
||
|
||
return saveSavedTabSessions(nextSessions);
|
||
}
|
||
|
||
async function appendSavedTabSessionTabs(sessionId, tabsToAppend, {
|
||
skipDuplicateUrls = true,
|
||
} = {}) {
|
||
const targetId = normalizeString(sessionId);
|
||
const normalizedTabs = Array.isArray(tabsToAppend)
|
||
? tabsToAppend.map(normalizeSessionTab).filter(Boolean)
|
||
: [];
|
||
if (!normalizedTabs.length) throw new Error('No restorable tabs selected');
|
||
|
||
const sessions = await getSavedTabSessions();
|
||
let updatedSession = null;
|
||
let appendedCount = 0;
|
||
let skippedDuplicateCount = 0;
|
||
const nextSessions = sessions.map(session => {
|
||
if (session.id !== targetId) return session;
|
||
|
||
const existingUrls = new Set((session.tabs || []).map(tab => tab.url).filter(Boolean));
|
||
const nextTabs = [...(session.tabs || [])];
|
||
|
||
for (const tab of normalizedTabs) {
|
||
if (skipDuplicateUrls && existingUrls.has(tab.url)) {
|
||
skippedDuplicateCount += 1;
|
||
continue;
|
||
}
|
||
nextTabs.push(tab);
|
||
existingUrls.add(tab.url);
|
||
appendedCount += 1;
|
||
}
|
||
|
||
updatedSession = {
|
||
...session,
|
||
tabs: nextTabs,
|
||
groups: buildSessionGroupsFromTabs(nextTabs),
|
||
};
|
||
return updatedSession;
|
||
});
|
||
|
||
if (!updatedSession) throw new Error('Session not found');
|
||
|
||
const savedSessions = await saveSavedTabSessions(nextSessions);
|
||
const savedSession = savedSessions.find(session => session.id === targetId) || updatedSession;
|
||
return {
|
||
sessions: savedSessions,
|
||
session: savedSession,
|
||
appendedCount,
|
||
skippedDuplicateCount,
|
||
};
|
||
}
|
||
|
||
const api = {
|
||
SAVED_TAB_SESSIONS_KEY,
|
||
buildSessionSnapshot,
|
||
buildSessionGroupsFromTabs,
|
||
createSavedTabSessionFromTab,
|
||
createRestoredSessionGroups,
|
||
deleteSavedTabSession,
|
||
getSavedTabSessions,
|
||
addSavedTabSession,
|
||
appendSavedTabSessionTabs,
|
||
renameSavedTabSession,
|
||
updateSavedTabSessionTabs,
|
||
normalizeSavedTabSessions,
|
||
saveSavedTabSessions,
|
||
};
|
||
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = api;
|
||
}
|
||
|
||
globalScope.TabHarborTabSessions = api;
|
||
})(typeof globalThis !== 'undefined' ? globalThis : window);
|