Files
v-iole-t--tab-harbor/extension/tab-sessions.js
T
2026-07-13 12:34:04 +08:00

494 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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);