mirror of
https://github.com/Dvorinka/Trackeep.git
synced 2026-06-03 12:03:00 +00:00
510 lines
13 KiB
JavaScript
510 lines
13 KiB
JavaScript
/* global chrome, browser */
|
|
|
|
if (typeof browser === "undefined" && typeof chrome !== "undefined") {
|
|
browser = chrome;
|
|
}
|
|
|
|
const YOUTUBE_PROMPT_HISTORY_KEY = "trackeepYoutubePromptHistory";
|
|
const YOUTUBE_AUTO_PROMPT_KEY = "youtubeAutoPrompt";
|
|
const YOUTUBE_DISMISS_COOLDOWN_MS = 6 * 60 * 60 * 1000;
|
|
const YOUTUBE_PROMPT_COOLDOWN_MS = 30 * 60 * 1000;
|
|
const YOUTUBE_HISTORY_LIMIT = 400;
|
|
|
|
function storageSyncGet(keys) {
|
|
return new Promise((resolve) => {
|
|
browser.storage.sync.get(keys, (items) => resolve(items || {}));
|
|
});
|
|
}
|
|
|
|
function storageLocalGet(keys) {
|
|
return new Promise((resolve) => {
|
|
browser.storage.local.get(keys, (items) => resolve(items || {}));
|
|
});
|
|
}
|
|
|
|
function storageLocalSet(values) {
|
|
return new Promise((resolve) => {
|
|
browser.storage.local.set(values, () => resolve());
|
|
});
|
|
}
|
|
|
|
function parseResponseError(status, body) {
|
|
if (!body) {
|
|
return `Request failed with status ${status}.`;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(body);
|
|
if (parsed && typeof parsed.error === "string" && parsed.error.trim()) {
|
|
return parsed.error.trim();
|
|
}
|
|
} catch (_) {
|
|
// Keep raw body fallback.
|
|
}
|
|
|
|
const trimmed = String(body).trim();
|
|
if (!trimmed) {
|
|
return `Request failed with status ${status}.`;
|
|
}
|
|
|
|
return trimmed.slice(0, 180);
|
|
}
|
|
|
|
function parseYouTubeVideoMeta(rawUrl, rawTitle) {
|
|
if (!rawUrl) {
|
|
return null;
|
|
}
|
|
|
|
let url;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
|
|
const host = url.hostname.toLowerCase();
|
|
const path = url.pathname || "";
|
|
let videoId = "";
|
|
|
|
if (host === "youtu.be") {
|
|
videoId = path.slice(1).split("/")[0];
|
|
} else if (path.startsWith("/watch")) {
|
|
videoId = url.searchParams.get("v") || "";
|
|
} else if (path.startsWith("/shorts/")) {
|
|
videoId = path.split("/")[2] || "";
|
|
} else if (path.startsWith("/embed/")) {
|
|
videoId = path.split("/")[2] || "";
|
|
}
|
|
|
|
if (!videoId) {
|
|
return null;
|
|
}
|
|
|
|
const title = (rawTitle || "").replace(/\s*-\s*YouTube$/i, "").trim();
|
|
return {
|
|
videoId,
|
|
url: `https://www.youtube.com/watch?v=${videoId}`,
|
|
title: title || "YouTube video",
|
|
};
|
|
}
|
|
|
|
function cleanupHistory(history) {
|
|
const entries = Object.entries(history || {});
|
|
if (entries.length <= YOUTUBE_HISTORY_LIMIT) {
|
|
return history || {};
|
|
}
|
|
|
|
entries.sort((a, b) => {
|
|
const aData = a[1] || {};
|
|
const bData = b[1] || {};
|
|
const aStamp = Math.max(aData.savedAt || 0, aData.dismissedAt || 0, aData.promptedAt || 0);
|
|
const bStamp = Math.max(bData.savedAt || 0, bData.dismissedAt || 0, bData.promptedAt || 0);
|
|
return bStamp - aStamp;
|
|
});
|
|
|
|
const compact = {};
|
|
for (const [videoId, value] of entries.slice(0, YOUTUBE_HISTORY_LIMIT)) {
|
|
compact[videoId] = value;
|
|
}
|
|
return compact;
|
|
}
|
|
|
|
async function getYouTubeHistory() {
|
|
const data = await storageLocalGet([YOUTUBE_PROMPT_HISTORY_KEY]);
|
|
return data[YOUTUBE_PROMPT_HISTORY_KEY] || {};
|
|
}
|
|
|
|
async function updateYouTubeHistory(videoId, patch) {
|
|
if (!videoId) {
|
|
return;
|
|
}
|
|
|
|
const history = await getYouTubeHistory();
|
|
const current = history[videoId] || {};
|
|
history[videoId] = { ...current, ...patch };
|
|
const compact = cleanupHistory(history);
|
|
await storageLocalSet({ [YOUTUBE_PROMPT_HISTORY_KEY]: compact });
|
|
}
|
|
|
|
async function getTrackeepConfig() {
|
|
const items = await storageSyncGet([
|
|
"trackeepApiBaseUrl",
|
|
"trackeepApiKey",
|
|
"trackeepAuthToken",
|
|
YOUTUBE_AUTO_PROMPT_KEY,
|
|
]);
|
|
|
|
const apiBaseUrl = String(items.trackeepApiBaseUrl || "").trim().replace(/\/+$/, "");
|
|
const authToken = String(items.trackeepApiKey || items.trackeepAuthToken || "").trim();
|
|
const youtubeAutoPrompt =
|
|
typeof items[YOUTUBE_AUTO_PROMPT_KEY] === "boolean"
|
|
? items[YOUTUBE_AUTO_PROMPT_KEY]
|
|
: true;
|
|
|
|
return {
|
|
apiBaseUrl,
|
|
authToken,
|
|
youtubeAutoPrompt,
|
|
};
|
|
}
|
|
|
|
async function shouldPromptForYouTubeVideo(video) {
|
|
if (!video || !video.videoId || !video.url) {
|
|
return { showPrompt: false, reason: "invalid-video" };
|
|
}
|
|
|
|
const config = await getTrackeepConfig();
|
|
if (!config.youtubeAutoPrompt) {
|
|
return { showPrompt: false, reason: "disabled" };
|
|
}
|
|
|
|
if (!config.apiBaseUrl || !config.authToken) {
|
|
return { showPrompt: false, reason: "missing-config" };
|
|
}
|
|
|
|
const history = await getYouTubeHistory();
|
|
const entry = history[video.videoId] || {};
|
|
const now = Date.now();
|
|
|
|
if (entry.savedAt) {
|
|
return { showPrompt: false, reason: "already-saved" };
|
|
}
|
|
|
|
if (entry.dismissedAt && now - entry.dismissedAt < YOUTUBE_DISMISS_COOLDOWN_MS) {
|
|
return { showPrompt: false, reason: "dismissed-recently" };
|
|
}
|
|
|
|
if (entry.promptedAt && now - entry.promptedAt < YOUTUBE_PROMPT_COOLDOWN_MS) {
|
|
return { showPrompt: false, reason: "prompted-recently" };
|
|
}
|
|
|
|
await updateYouTubeHistory(video.videoId, {
|
|
promptedAt: now,
|
|
title: video.title,
|
|
url: video.url,
|
|
});
|
|
|
|
return { showPrompt: true };
|
|
}
|
|
|
|
async function saveYouTubeBookmark(video) {
|
|
if (!video || !video.videoId || !video.url) {
|
|
return { ok: false, error: "Invalid YouTube video metadata." };
|
|
}
|
|
|
|
const config = await getTrackeepConfig();
|
|
if (!config.apiBaseUrl || !config.authToken) {
|
|
return {
|
|
ok: false,
|
|
error: "Trackeep API URL or token missing. Open extension options first.",
|
|
};
|
|
}
|
|
|
|
const title = (video.title || "YouTube video").trim();
|
|
const payload = {
|
|
title,
|
|
url: video.url,
|
|
description: "Saved from YouTube via Trackeep browser extension",
|
|
tags: ["youtube", "video"],
|
|
is_public: false,
|
|
};
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(`${config.apiBaseUrl}/bookmarks`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Authorization": `Bearer ${config.authToken}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
error: `Could not connect to Trackeep: ${error && error.message ? error.message : "network error"}`,
|
|
};
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => "");
|
|
return {
|
|
ok: false,
|
|
error: parseResponseError(response.status, body),
|
|
};
|
|
}
|
|
|
|
await updateYouTubeHistory(video.videoId, {
|
|
savedAt: Date.now(),
|
|
title: title,
|
|
url: video.url,
|
|
});
|
|
|
|
return { ok: true };
|
|
}
|
|
|
|
async function openPopupWithContext(data) {
|
|
await storageLocalSet({ contextMenuData: data });
|
|
try {
|
|
const maybePromise = browser.action.openPopup();
|
|
if (maybePromise && typeof maybePromise.then === "function") {
|
|
await maybePromise;
|
|
}
|
|
return { ok: true };
|
|
} catch (_) {
|
|
return { ok: false, error: "Cannot open popup automatically in this browser context." };
|
|
}
|
|
}
|
|
|
|
function setContextAndOpenPopup(tab, selection, isQuickSave, smartData) {
|
|
const payload = {
|
|
url: tab?.url || "",
|
|
title: tab?.title || "",
|
|
selection: selection || "",
|
|
timestamp: Date.now(),
|
|
isQuickSave: !!isQuickSave,
|
|
smartData: smartData || null,
|
|
};
|
|
|
|
return openPopupWithContext(payload);
|
|
}
|
|
|
|
browser.commands.onCommand.addListener((command) => {
|
|
if (command !== "quick-save") {
|
|
return;
|
|
}
|
|
|
|
browser.tabs.query({ active: true, currentWindow: true }, async (tabs) => {
|
|
const tab = tabs && tabs[0];
|
|
if (!tab) {
|
|
return;
|
|
}
|
|
|
|
const info = { linkUrl: tab.url, srcUrl: tab.url };
|
|
const smartData = await detectContentType(info, tab);
|
|
await setContextAndOpenPopup(tab, "", true, smartData);
|
|
});
|
|
});
|
|
|
|
browser.runtime.onInstalled.addListener((details) => {
|
|
if (details.reason === "install") {
|
|
browser.storage.sync.set(
|
|
{
|
|
isFirstInstall: true,
|
|
installDate: new Date().toISOString(),
|
|
[YOUTUBE_AUTO_PROMPT_KEY]: true,
|
|
},
|
|
() => {
|
|
browser.runtime.openOptionsPage();
|
|
}
|
|
);
|
|
}
|
|
|
|
browser.contextMenus.create({
|
|
id: "save-to-trackeep",
|
|
title: "Save to Trackeep",
|
|
contexts: ["page", "link", "selection", "image", "video"],
|
|
});
|
|
|
|
browser.contextMenus.create({
|
|
id: "quick-save-to-trackeep",
|
|
title: "Quick Save to Trackeep",
|
|
contexts: ["page"],
|
|
});
|
|
});
|
|
|
|
browser.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
if (info.menuItemId !== "save-to-trackeep" && info.menuItemId !== "quick-save-to-trackeep") {
|
|
return;
|
|
}
|
|
|
|
const smartData = await detectContentType(info, tab);
|
|
const url = info.linkUrl || info.srcUrl || tab?.url || "";
|
|
const title = tab?.title || "";
|
|
const selection = info.selectionText || "";
|
|
|
|
await openPopupWithContext({
|
|
url,
|
|
title,
|
|
selection,
|
|
timestamp: Date.now(),
|
|
isQuickSave: info.menuItemId === "quick-save-to-trackeep",
|
|
smartData,
|
|
});
|
|
});
|
|
|
|
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (!message || !message.type) {
|
|
return undefined;
|
|
}
|
|
|
|
(async () => {
|
|
if (message.type === "trackeep:youtube-video-detected") {
|
|
return shouldPromptForYouTubeVideo(message.video);
|
|
}
|
|
|
|
if (message.type === "trackeep:youtube-dismissed") {
|
|
if (message.videoId) {
|
|
await updateYouTubeHistory(message.videoId, { dismissedAt: Date.now() });
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
if (message.type === "trackeep:youtube-save-request") {
|
|
return saveYouTubeBookmark(message.video);
|
|
}
|
|
|
|
if (message.type === "trackeep:youtube-open-saver") {
|
|
const tab = sender && sender.tab ? sender.tab : null;
|
|
const video = parseYouTubeVideoMeta(message.video && message.video.url, message.video && message.video.title);
|
|
if (!tab || !video) {
|
|
return { ok: false, error: "Could not open saver for this video." };
|
|
}
|
|
|
|
const smartData = {
|
|
type: "video",
|
|
platform: "youtube",
|
|
suggestedTags: ["youtube", "video"],
|
|
};
|
|
|
|
return openPopupWithContext({
|
|
url: video.url,
|
|
title: video.title,
|
|
selection: "",
|
|
timestamp: Date.now(),
|
|
isQuickSave: false,
|
|
smartData,
|
|
});
|
|
}
|
|
|
|
return undefined;
|
|
})()
|
|
.then((result) => sendResponse(result))
|
|
.catch((error) => {
|
|
sendResponse({
|
|
ok: false,
|
|
error: error && error.message ? error.message : "Unexpected extension error.",
|
|
});
|
|
});
|
|
|
|
return true;
|
|
});
|
|
|
|
async function detectContentType(info, tab) {
|
|
const url = info.linkUrl || info.srcUrl || tab?.url || "";
|
|
const title = tab?.title || "";
|
|
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const domain = urlObj.hostname.toLowerCase();
|
|
|
|
const ytMeta = parseYouTubeVideoMeta(url, title);
|
|
if (ytMeta) {
|
|
return {
|
|
type: "video",
|
|
platform: "youtube",
|
|
suggestedTags: ["video", "youtube"],
|
|
autoTitle: ytMeta.title,
|
|
};
|
|
}
|
|
|
|
if (url.includes("vimeo.com") || url.includes("dailymotion.com")) {
|
|
return {
|
|
type: "video",
|
|
platform: domain.replace(".com", ""),
|
|
suggestedTags: ["video", domain.replace(".com", "")],
|
|
};
|
|
}
|
|
|
|
if (domain.includes("twitter.com") || domain.includes("x.com")) {
|
|
return {
|
|
type: "social",
|
|
platform: "twitter",
|
|
suggestedTags: ["social", "twitter", "tweet"],
|
|
};
|
|
}
|
|
|
|
if (domain.includes("linkedin.com")) {
|
|
return {
|
|
type: "social",
|
|
platform: "linkedin",
|
|
suggestedTags: ["social", "linkedin", "professional"],
|
|
};
|
|
}
|
|
|
|
if (domain.includes("reddit.com")) {
|
|
return {
|
|
type: "social",
|
|
platform: "reddit",
|
|
suggestedTags: ["social", "reddit", "discussion"],
|
|
};
|
|
}
|
|
|
|
if (domain.includes("github.com")) {
|
|
return {
|
|
type: "code",
|
|
platform: "github",
|
|
suggestedTags: ["code", "github", "development", "repository"],
|
|
};
|
|
}
|
|
|
|
if (domain.includes("stackoverflow.com")) {
|
|
return {
|
|
type: "code",
|
|
platform: "stackoverflow",
|
|
suggestedTags: ["code", "stackoverflow", "programming", "qa"],
|
|
};
|
|
}
|
|
|
|
if (domain.includes("medium.com")) {
|
|
return {
|
|
type: "article",
|
|
platform: "medium",
|
|
suggestedTags: ["article", "blog", "medium"],
|
|
};
|
|
}
|
|
|
|
if (domain.includes("docs.") || domain.includes("documentation")) {
|
|
return {
|
|
type: "documentation",
|
|
suggestedTags: ["documentation", "docs", "reference"],
|
|
};
|
|
}
|
|
|
|
if (
|
|
domain.includes("news.") ||
|
|
domain.includes("cnn.com") ||
|
|
domain.includes("bbc.com") ||
|
|
domain.includes("reuters.com") ||
|
|
domain.includes("washingtonpost.com")
|
|
) {
|
|
return {
|
|
type: "news",
|
|
suggestedTags: ["news", "article", "current-events"],
|
|
};
|
|
}
|
|
|
|
if (
|
|
domain.includes("amazon.com") ||
|
|
domain.includes("ebay.com") ||
|
|
domain.includes("shopify.com") ||
|
|
domain.includes("etsy.com")
|
|
) {
|
|
return {
|
|
type: "shopping",
|
|
suggestedTags: ["shopping", "product", "ecommerce"],
|
|
};
|
|
}
|
|
|
|
return {
|
|
type: "general",
|
|
suggestedTags: ["bookmark", "webpage"],
|
|
};
|
|
} catch (_) {
|
|
return {
|
|
type: "general",
|
|
suggestedTags: ["bookmark", "webpage"],
|
|
};
|
|
}
|
|
}
|