small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:01 +02:00
parent 954a1a1080
commit c6a99c7e21
214 changed files with 40237 additions and 2828 deletions
+13 -9
View File
@@ -5,6 +5,7 @@ This folder contains a WebExtension (Manifest v3) that lets you:
- **Save the current page or video** as a **Trackeep bookmark**.
- **Upload a local file** directly to Trackeep.
- **Rightclick** any page, link, selection, image, or video and choose **“Save to Trackeep”** from the context menu.
- **Auto-detect YouTube videos** and show an in-page prompt asking whether to save to Trackeep.
It is designed to work in **Chrome**, **Microsoft Edge**, and **Firefox** (Manifest v3 where available).
@@ -14,8 +15,9 @@ It is designed to work in **Chrome**, **Microsoft Edge**, and **Firefox** (Manif
- `manifest.json` WebExtension manifest (v3) with background service worker and context menu.
- `popup.html` / `popup.js` Popup UI and logic to save bookmarks and upload files.
- `options.html` / `options.js` Options page to configure API URL and auth token.
- `background.js` Service worker that creates and handles the context menu.
- `options.html` / `options.js` Options page to configure API URL, key/token, and YouTube auto-prompt.
- `background.js` Service worker for context menus, quick-save, and YouTube auto-save integration.
- `youtube-content.js` Content script for YouTube page detection and in-page save prompt.
- `icons/` Placeholder icon files (copied from the repo favicon).
- `README.md` This documentation.
@@ -62,14 +64,16 @@ It is designed to work in **Chrome**, **Microsoft Edge**, and **Firefox** (Manif
- Example for production:
- `https://your-trackeep-domain.example.com/api/v1`
3. **Get your Trackeep auth token**
- Log into Trackeep in your browser.
- Open **DevTools → Application → Local Storage**.
- Select your Trackeep origin (e.g. `http://localhost:5173` or your production domain).
- Find the key `trackeep_token` and copy its **value**.
- Paste this value into the **Auth token** field in the options page.
3. **Set your Trackeep API key or token**
- Recommended: create an API key in Trackeep and paste it in the options page.
- You can also use a JWT token if your instance expects that flow.
- The extension stores this value and uses it for bookmark/file requests and YouTube quick-save.
4. **Save settings**
4. **YouTube auto prompt (optional)**
- Keep **YouTube Auto Prompt** enabled to get an in-page prompt on YouTube video pages.
- Click **Save now** in the prompt to store the video directly in Trackeep.
5. **Save settings**
- Click **Save settings**.
- The popup will now use these values to call the API.
+486 -194
View File
@@ -1,217 +1,509 @@
/* global chrome, browser */
// Browser compatibility polyfill
if (typeof browser === 'undefined' && typeof chrome !== 'undefined') {
if (typeof browser === "undefined" && typeof chrome !== "undefined") {
browser = chrome;
}
// Handle keyboard commands
browser.commands.onCommand.addListener((command) => {
if (command === 'quick-save') {
// Get current tab and trigger quick save
browser.tabs.query({ active: true, currentWindow: true }, (tabs) => {
const tab = tabs && tabs[0];
if (tab) {
browser.storage.local.set({
contextMenuData: {
url: tab.url,
title: tab.title,
selection: '',
timestamp: Date.now(),
isQuickSave: true
}
}, () => {
browser.action.openPopup();
});
}
});
}
});
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;
// Handle first-time install
browser.runtime.onInstalled.addListener((details) => {
if (details.reason === 'install') {
// Set up first-time install flag
browser.storage.sync.set({
isFirstInstall: true,
installDate: new Date().toISOString()
}, () => {
// Open options page for first-time setup
browser.runtime.openOptionsPage();
});
}
// Create context menus
browser.contextMenus.create({
id: 'save-to-trackeep',
title: 'Save to Trackeep',
contexts: ['page', 'link', 'selection', 'image', 'video']
function storageSyncGet(keys) {
return new Promise((resolve) => {
browser.storage.sync.get(keys, (items) => resolve(items || {}));
});
// Quick save menu
browser.contextMenus.create({
id: 'quick-save-to-trackeep',
title: 'Quick Save to Trackeep',
contexts: ['page']
}
function storageLocalGet(keys) {
return new Promise((resolve) => {
browser.storage.local.get(keys, (items) => resolve(items || {}));
});
});
}
// Handle context menu click
browser.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== 'save-to-trackeep' && info.menuItemId !== 'quick-save-to-trackeep') return;
function storageLocalSet(values) {
return new Promise((resolve) => {
browser.storage.local.set(values, () => resolve());
});
}
// Detect content type and get smart data
const smartData = await detectContentType(info, tab);
// Open popup with pre-filled data based on context
const url = info.linkUrl || info.srcUrl || tab?.url || '';
const title = tab?.title || '';
const selection = info.selectionText || '';
function parseResponseError(status, body) {
if (!body) {
return `Request failed with status ${status}.`;
}
// Store temporary data for popup to read
browser.storage.local.set({
contextMenuData: {
url,
title,
selection,
timestamp: Date.now(),
isQuickSave: info.menuItemId === 'quick-save-to-trackeep',
smartData
try {
const parsed = JSON.parse(body);
if (parsed && typeof parsed.error === "string" && parsed.error.trim()) {
return parsed.error.trim();
}
}, () => {
// Open popup (or focus it if already open)
browser.action.openPopup();
} 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);
});
});
// Smart content detection
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 || '';
const url = info.linkUrl || info.srcUrl || tab?.url || "";
const title = tab?.title || "";
try {
const urlObj = new URL(url);
const domain = urlObj.hostname.toLowerCase();
// Video detection
if (url.includes('youtube.com/watch') || url.includes('youtu.be/')) {
return {
type: 'video',
platform: 'youtube',
suggestedTags: ['video', 'youtube', 'educational'],
autoTitle: extractYouTubeTitle(url) || title
};
}
if (url.includes('vimeo.com') || url.includes('dailymotion.com')) {
return {
type: 'video',
platform: domain.replace('.com', ''),
suggestedTags: ['video', domain.replace('.com', '')]
};
}
// Social media detection
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']
};
}
// Development platforms
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']
};
}
// Documentation
if (domain.includes('docs.') || domain.includes('documentation')) {
return {
type: 'documentation',
suggestedTags: ['documentation', 'docs', 'reference']
};
}
// News sites
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']
};
}
// E-commerce
if (domain.includes('amazon.com') || domain.includes('ebay.com') ||
domain.includes('shopify.com') || domain.includes('etsy.com')) {
return {
type: 'shopping',
suggestedTags: ['shopping', 'product', 'ecommerce']
};
}
// Default detection
return {
type: 'general',
suggestedTags: ['bookmark', 'webpage']
};
} catch (e) {
return {
type: 'general',
suggestedTags: ['bookmark', 'webpage']
};
}
}
// Extract YouTube video title
function extractYouTubeTitle(url) {
try {
const urlObj = new URL(url);
const videoId = urlObj.searchParams.get('v');
if (videoId) {
// In a real implementation, you might fetch YouTube API
// For now, return null and let the page title be used
return null;
const ytMeta = parseYouTubeVideoMeta(url, title);
if (ytMeta) {
return {
type: "video",
platform: "youtube",
suggestedTags: ["video", "youtube"],
autoTitle: ytMeta.title,
};
}
} catch (e) {
return null;
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"],
};
}
}
+14
View File
@@ -21,6 +21,20 @@
"host_permissions": [
"<all_urls>"
],
"content_scripts": [
{
"matches": [
"*://www.youtube.com/*",
"*://youtube.com/*",
"*://m.youtube.com/*",
"*://youtu.be/*"
],
"js": [
"youtube-content.js"
],
"run_at": "document_idle"
}
],
"commands": {
"quick-save": {
"suggested_key": {
+11 -1
View File
@@ -847,13 +847,23 @@
More secure than JWT tokens, revocable anytime
</div>
</div>
<div class="form-group">
<label for="youtubeAutoPrompt">YouTube Auto Prompt</label>
<div class="input-help">
<label style="display: inline-flex; align-items: center; gap: 8px; cursor: pointer; text-transform: none; letter-spacing: normal; margin: 0;">
<input type="checkbox" id="youtubeAutoPrompt" style="width: 16px; height: 16px;" />
<span>Ask to save when a YouTube video is detected</span>
</label>
</div>
</div>
<div id="connectionStatus" class="connection-status" style="display: none;">
<div class="status-content">
<img src="https://www.svgrepo.com/show/448375/connection-gateway.svg" alt="Status" class="icon" style="width: 16px; height: 16px;" />
<div>
<strong id="statusTitle">Testing Connection...</strong>
<p id="statusMessage">Please wait</p>
<p id="connectionStatusMessage">Please wait</p>
</div>
</div>
</div>
+87 -68
View File
@@ -7,13 +7,14 @@ if (typeof browser === 'undefined' && typeof chrome !== 'undefined') {
const apiBaseUrlInput = document.getElementById('trackeepApiUrl');
const apiKeyInput = document.getElementById('trackeepApiKey');
const youtubeAutoPromptInput = document.getElementById('youtubeAutoPrompt');
const testConnectionBtn = document.getElementById('testConnectionBtn');
const generateKeyBtn = document.getElementById('generateKeyBtn');
const saveBtn = document.getElementById('saveBtn');
const statusMessageEl = document.getElementById('statusMessage');
const connectionStatusEl = document.getElementById('connectionStatus');
const statusTitleEl = document.getElementById('statusTitle');
const statusTextEl = document.getElementById('statusMessage');
const statusTextEl = document.getElementById('connectionStatusMessage');
const installWelcomeEl = document.getElementById('installWelcome');
const mainOptionsEl = document.getElementById('mainOptions');
@@ -99,7 +100,13 @@ function detectAndPrefillApiBaseUrl(callback) {
}
function loadSettings() {
browser.storage.sync.get(['trackeepApiBaseUrl', 'trackeepApiKey', 'isFirstInstall'], (items) => {
browser.storage.sync.get([
'trackeepApiBaseUrl',
'trackeepApiKey',
'trackeepAuthToken',
'youtubeAutoPrompt',
'isFirstInstall'
], (items) => {
// Handle first-time install
if (items.isFirstInstall) {
installWelcomeEl.style.display = 'flex';
@@ -113,8 +120,13 @@ function loadSettings() {
if (items.trackeepApiBaseUrl) {
apiBaseUrlInput.value = items.trackeepApiBaseUrl;
}
if (items.trackeepApiKey) {
apiKeyInput.value = items.trackeepApiKey;
if (items.trackeepApiKey || items.trackeepAuthToken) {
apiKeyInput.value = items.trackeepApiKey || items.trackeepAuthToken;
}
if (youtubeAutoPromptInput) {
youtubeAutoPromptInput.checked = typeof items.youtubeAutoPrompt === 'boolean'
? items.youtubeAutoPrompt
: true;
}
// Auto-detect API URL if empty
@@ -126,20 +138,16 @@ function loadSettings() {
function saveSettings() {
const apiBaseUrl = apiBaseUrlInput.value.trim();
const apiKey = apiKeyInput.value.trim();
const token = apiKeyInput.value.trim();
const youtubeAutoPrompt = youtubeAutoPromptInput ? !!youtubeAutoPromptInput.checked : true;
if (!apiBaseUrl) {
showMessage('API base URL is required.', 'error');
return;
}
if (!apiKey) {
showMessage('API key is required.', 'error');
return;
}
if (!apiKey.startsWith('tk_')) {
showMessage('API key should start with "tk_"', 'error');
if (!token) {
showMessage('API key or token is required.', 'error');
return;
}
@@ -148,7 +156,9 @@ function saveSettings() {
browser.storage.sync.set({
trackeepApiBaseUrl: apiBaseUrl,
trackeepApiKey: apiKey,
trackeepApiKey: token,
trackeepAuthToken: token,
youtubeAutoPrompt,
isFirstInstall: false
}, () => {
setButtonLoading(saveBtn, false);
@@ -156,38 +166,55 @@ function saveSettings() {
});
}
async function validateConnectionToken(apiBaseUrl, token) {
const base = apiBaseUrl.replace(/\/$/, '');
const isApiKey = token.startsWith('tk_');
const endpoint = isApiKey ? `${base}/browser-extension/validate` : `${base}/auth/me`;
const response = await fetch(endpoint, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
let payload = {};
try {
payload = await response.json();
} catch (_) {
// Keep empty payload for non-JSON responses.
}
return { isApiKey, payload };
}
async function testConnection() {
const apiBaseUrl = apiBaseUrlInput.value.trim();
const apiKey = apiKeyInput.value.trim();
const token = apiKeyInput.value.trim();
if (!apiBaseUrl || !apiKey) {
showConnectionStatus('Connection Failed', 'Please enter both URL and API key', 'error');
if (!apiBaseUrl || !token) {
showConnectionStatus('Connection Failed', 'Please enter both URL and token', 'error');
return;
}
showConnectionStatus('Testing Connection', 'Connecting to your Trackeep instance...', 'info');
try {
const base = apiBaseUrl.replace(/\/$/, '');
const response = await fetch(`${base}/auth/me`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
showConnectionStatus('Connection Successful', `Connected as ${data.username || 'user'}. API key is valid!`, 'success');
// Hide success message after 3 seconds
setTimeout(() => {
hideConnectionStatus();
}, 3000);
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await validateConnectionToken(apiBaseUrl, token);
const identity = result.payload && result.payload.username ? result.payload.username : 'user';
showConnectionStatus(
'Connection Successful',
`Connected as ${identity}. ${result.isApiKey ? 'API key' : 'Token'} is valid.`,
'success'
);
setTimeout(() => {
hideConnectionStatus();
}, 3000);
} catch (error) {
showConnectionStatus('Connection Failed', `Error: ${error.message}`, 'error');
}
@@ -290,40 +317,30 @@ document.addEventListener('DOMContentLoaded', () => {
// Test connection from setup form
async function testSetupConnection() {
const apiBaseUrl = document.getElementById('setupApiUrl').value.trim();
const apiKey = document.getElementById('setupApiKey').value.trim();
const token = document.getElementById('setupApiKey').value.trim();
if (!apiBaseUrl || !apiKey) {
showSetupConnectionStatus('Connection Failed', 'Please enter both URL and API key', 'error');
if (!apiBaseUrl || !token) {
showSetupConnectionStatus('Connection Failed', 'Please enter both URL and token', 'error');
return;
}
showSetupConnectionStatus('Testing Connection', 'Connecting to your Trackeep instance...', 'info');
try {
const base = apiBaseUrl.replace(/\/$/, '');
const response = await fetch(`${base}/auth/me`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
const data = await response.json();
showSetupConnectionStatus('Connection Successful', `Connected as ${data.username || 'user'}. API key is valid!`, 'success');
// Copy values to main form
document.getElementById('trackeepApiUrl').value = apiBaseUrl;
document.getElementById('trackeepApiKey').value = apiKey;
// Hide success message after 3 seconds
setTimeout(() => {
hideSetupConnectionStatus();
}, 3000);
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await validateConnectionToken(apiBaseUrl, token);
const identity = result.payload && result.payload.username ? result.payload.username : 'user';
showSetupConnectionStatus(
'Connection Successful',
`Connected as ${identity}. ${result.isApiKey ? 'API key' : 'Token'} is valid.`,
'success'
);
document.getElementById('trackeepApiUrl').value = apiBaseUrl;
document.getElementById('trackeepApiKey').value = token;
setTimeout(() => {
hideSetupConnectionStatus();
}, 3000);
} catch (error) {
showSetupConnectionStatus('Connection Failed', `Error: ${error.message}`, 'error');
}
@@ -332,17 +349,19 @@ async function testSetupConnection() {
// Complete setup
function completeSetup() {
const apiBaseUrl = document.getElementById('setupApiUrl').value.trim();
const apiKey = document.getElementById('setupApiKey').value.trim();
const token = document.getElementById('setupApiKey').value.trim();
if (!apiBaseUrl || !apiKey) {
showMessage('Please fill in both URL and API key', 'error');
if (!apiBaseUrl || !token) {
showMessage('Please fill in both URL and token', 'error');
return;
}
// Save settings
browser.storage.sync.set({
trackeepApiBaseUrl: apiBaseUrl,
trackeepApiKey: apiKey,
trackeepApiKey: token,
trackeepAuthToken: token,
youtubeAutoPrompt: true,
isFirstInstall: false
}, () => {
showMessage('Setup completed successfully!', 'success');
@@ -353,7 +372,7 @@ function completeSetup() {
// Load settings in main form
document.getElementById('trackeepApiUrl').value = apiBaseUrl;
document.getElementById('trackeepApiKey').value = apiKey;
document.getElementById('trackeepApiKey').value = token;
});
}
File diff suppressed because it is too large Load Diff
+27 -28
View File
@@ -125,9 +125,9 @@ function disableForms(disabled) {
}
function loadConfig(callback) {
browser.storage.sync.get(['trackeepApiBaseUrl', 'trackeepAuthToken'], (items) => {
browser.storage.sync.get(['trackeepApiBaseUrl', 'trackeepApiKey', 'trackeepAuthToken'], (items) => {
const apiBaseUrl = (items.trackeepApiBaseUrl || '').trim();
const authToken = (items.trackeepAuthToken || '').trim();
const authToken = (items.trackeepApiKey || items.trackeepAuthToken || '').trim();
trackeepConfig = { apiBaseUrl, authToken };
@@ -158,7 +158,7 @@ function detectTrackeepDomain(callback) {
try {
const url = new URL(tab.url);
const isTrackeepDomain = url.hostname.includes('trackeep') || url.hostname === 'localhost';
if (isTrackeepDomain && url.protocol === 'https:') {
if (isTrackeepDomain && (url.protocol === 'https:' || url.protocol === 'http:')) {
const candidate = `${url.origin}/api/v1`;
browser.storage.sync.get(['trackeepApiBaseUrl'], (items) => {
if (!items.trackeepApiBaseUrl) {
@@ -305,23 +305,30 @@ function addSuggestedTag(tag) {
// Handle quick save
function handleQuickSave() {
if (isQuickSaveMode && smartData) {
// Auto-fill with smart data and save immediately
if (smartData.suggestedTags && !bookmarkTagsInput.value) {
bookmarkTagsInput.value = smartData.suggestedTags.join(', ');
}
// Auto-save after a short delay
// Auto-fill with smart data where available.
if (smartData && smartData.suggestedTags && !bookmarkTagsInput.value) {
bookmarkTagsInput.value = smartData.suggestedTags.join(', ');
}
const hasRequiredFields = bookmarkUrlInput.value.trim() && bookmarkTitleInput.value.trim();
if (!hasRequiredFields) {
showMessage('URL and title are required before quick save.', 'error');
return;
}
if (isQuickSaveMode) {
setTimeout(() => {
if (bookmarkUrlInput.value && bookmarkTitleInput.value) {
saveBookmark(new Event('submit'));
}
}, 500);
saveBookmark();
}, 350);
} else {
saveBookmark();
}
}
async function saveBookmark(event) {
event.preventDefault();
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
hideMessage();
const { apiBaseUrl, authToken } = trackeepConfig;
@@ -380,12 +387,7 @@ async function saveBookmark(event) {
throw new Error(errorMessage);
}
showMessage(`
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20,6 9,17 4,12"/>
</svg>
Bookmark saved successfully!
`, 'success');
showMessage('Bookmark saved successfully!', 'success');
// Clear form after successful save
setTimeout(() => {
@@ -403,7 +405,9 @@ async function saveBookmark(event) {
}
async function uploadFile(event) {
event.preventDefault();
if (event && typeof event.preventDefault === 'function') {
event.preventDefault();
}
hideMessage();
const { apiBaseUrl, authToken } = trackeepConfig;
@@ -452,12 +456,7 @@ async function uploadFile(event) {
throw new Error(errorMessage);
}
showMessage(`
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="20,6 9,17 4,12"/>
</svg>
File uploaded successfully!
`, 'success');
showMessage('File uploaded successfully!', 'success');
// Clear form after successful upload
setTimeout(() => {
+298
View File
@@ -0,0 +1,298 @@
/* global chrome, browser */
if (typeof browser === "undefined" && typeof chrome !== "undefined") {
browser = chrome;
}
const PROMPT_HOST_ID = "__trackeepYoutubePromptHost";
let lastNotifiedVideoId = "";
let lastUrl = "";
function parseYouTubeVideo(url, title) {
let parsed;
try {
parsed = new URL(url);
} catch (_) {
return null;
}
const host = parsed.hostname.toLowerCase();
const path = parsed.pathname || "";
let videoId = "";
if (host === "youtu.be") {
videoId = path.slice(1).split("/")[0];
} else if (path.startsWith("/watch")) {
videoId = parsed.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 cleanedTitle = (title || "").replace(/\s*-\s*YouTube$/i, "").trim();
return {
videoId,
url: `https://www.youtube.com/watch?v=${videoId}`,
title: cleanedTitle || "YouTube video",
};
}
function sendMessage(message, callback) {
try {
browser.runtime.sendMessage(message, callback);
} catch (_) {
if (typeof callback === "function") {
callback(undefined);
}
}
}
function closePrompt() {
const existing = document.getElementById(PROMPT_HOST_ID);
if (existing) {
existing.remove();
}
}
function renderPrompt(video) {
closePrompt();
const host = document.createElement("div");
host.id = PROMPT_HOST_ID;
const shadow = host.attachShadow({ mode: "open" });
shadow.innerHTML = `
<style>
:host {
all: initial;
}
.card {
position: fixed;
top: 20px;
right: 20px;
width: 320px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: linear-gradient(180deg, rgba(17, 20, 31, 0.96) 0%, rgba(9, 11, 18, 0.98) 100%);
color: #e9edf8;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.38);
padding: 14px;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
backdrop-filter: blur(18px);
z-index: 2147483647;
transform: translateY(-10px);
opacity: 0;
animation: trackeep-slide-in 180ms ease-out forwards;
}
@keyframes trackeep-slide-in {
to {
opacity: 1;
transform: translateY(0);
}
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.05);
border-radius: 999px;
padding: 4px 9px;
font-size: 11px;
line-height: 1;
color: #b5c0db;
}
h3 {
margin: 10px 0 6px;
font-size: 14px;
line-height: 1.35;
color: #f8faff;
}
p {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: #b5c0db;
}
.actions {
margin-top: 12px;
display: flex;
gap: 8px;
}
button {
border: 0;
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.primary {
background: linear-gradient(135deg, #4d7dff 0%, #3859f8 100%);
color: #ffffff;
flex: 1;
}
.secondary {
background: rgba(255, 255, 255, 0.08);
color: #e9edf8;
}
.ghost {
background: transparent;
color: #9fa9c8;
}
.status {
margin-top: 10px;
min-height: 17px;
font-size: 12px;
}
.status.error {
color: #ff8e8e;
}
.status.ok {
color: #73e2b8;
}
.truncate {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
<div class="card">
<span class="tag">Trackeep · YouTube</span>
<h3>Save this video to your Trackeep?</h3>
<p class="truncate" title="${escapeHtml(video.title)}">${escapeHtml(video.title)}</p>
<div class="actions">
<button class="primary" id="saveNow">Save now</button>
<button class="secondary" id="openSaver">Open saver</button>
<button class="ghost" id="later">Later</button>
</div>
<div class="status" id="status"></div>
</div>
`;
document.documentElement.appendChild(host);
const saveBtn = shadow.getElementById("saveNow");
const openBtn = shadow.getElementById("openSaver");
const laterBtn = shadow.getElementById("later");
const statusEl = shadow.getElementById("status");
const setStatus = (message, type) => {
statusEl.textContent = message || "";
statusEl.className = `status${type ? ` ${type}` : ""}`;
};
saveBtn.addEventListener("click", () => {
saveBtn.disabled = true;
openBtn.disabled = true;
laterBtn.disabled = true;
setStatus("Saving to Trackeep...", "");
sendMessage({ type: "trackeep:youtube-save-request", video }, (response) => {
if (browser.runtime && browser.runtime.lastError) {
setStatus("Could not reach extension background worker.", "error");
} else if (response && response.ok) {
setStatus("Saved successfully.", "ok");
setTimeout(closePrompt, 1200);
} else {
setStatus(
response && response.error
? response.error
: "Save failed. Open saver to review.",
"error"
);
saveBtn.disabled = false;
openBtn.disabled = false;
laterBtn.disabled = false;
}
});
});
openBtn.addEventListener("click", () => {
sendMessage({ type: "trackeep:youtube-open-saver", video }, () => {
closePrompt();
});
});
laterBtn.addEventListener("click", () => {
sendMessage({ type: "trackeep:youtube-dismissed", videoId: video.videoId }, () => {
closePrompt();
});
});
window.setTimeout(() => {
closePrompt();
}, 18000);
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function detectAndNotify(force) {
const video = parseYouTubeVideo(window.location.href, document.title);
if (!video) {
closePrompt();
return;
}
if (!force && video.videoId === lastNotifiedVideoId && window.location.href === lastUrl) {
return;
}
lastNotifiedVideoId = video.videoId;
lastUrl = window.location.href;
sendMessage({ type: "trackeep:youtube-video-detected", video }, (response) => {
if (browser.runtime && browser.runtime.lastError) {
return;
}
if (response && response.showPrompt) {
renderPrompt(video);
} else {
closePrompt();
}
});
}
function initDetection() {
let previousHref = window.location.href;
const check = () => {
if (window.location.href !== previousHref) {
previousHref = window.location.href;
detectAndNotify(true);
}
};
window.addEventListener("yt-navigate-finish", () => detectAndNotify(true), true);
window.addEventListener("popstate", () => detectAndNotify(true), true);
document.addEventListener("visibilitychange", () => {
if (!document.hidden) {
detectAndNotify(false);
}
});
const observer = new MutationObserver(check);
observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
window.setInterval(check, 1200);
detectAndNotify(true);
}
initDetection();