diff --git a/backend/server.js b/backend/server.js index 5d04a2e..776e78d 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4,6 +4,7 @@ const Database = require('better-sqlite3'); const { v4: uuidv4 } = require('uuid'); const path = require('path'); const fs = require('fs'); +const crypto = require('crypto'); const app = express(); const PORT = process.env.PORT || 3000; @@ -21,6 +22,8 @@ const PROFILE_SCOPE_MAX_AGE = 60 * 60 * 24 * 30; // 30 days const FACEBOOK_TRACKING_PARAM_PREFIXES = ['__cft__', '__tn__', '__eep__', 'mibextid']; const SEARCH_POST_HIDE_THRESHOLD = 2; const SEARCH_POST_RETENTION_DAYS = 90; +const MAX_POST_TEXT_LENGTH = 4000; +const MIN_TEXT_HASH_LENGTH = 120; const screenshotDir = path.join(__dirname, 'data', 'screenshots'); if (!fs.existsSync(screenshotDir)) { @@ -65,6 +68,53 @@ const dbPath = path.join(__dirname, 'data', 'tracker.db'); const db = new Database(dbPath); db.pragma('foreign_keys = ON'); +function ensureColumn(table, column, definition) { + const info = db.prepare(`PRAGMA table_info(${table})`).all(); + if (!info.some((row) => row.name === column)) { + db.prepare(`ALTER TABLE ${table} ADD COLUMN ${definition}`).run(); + } +} + +ensureColumn('posts', 'post_text', 'post_text TEXT'); +ensureColumn('posts', 'post_text_hash', 'post_text_hash TEXT'); +ensureColumn('posts', 'content_key', 'content_key TEXT'); + +db.exec(` + CREATE INDEX IF NOT EXISTS idx_posts_content_key + ON posts(content_key) +`); + +const updateContentKeyStmt = db.prepare('UPDATE posts SET content_key = ? WHERE id = ?'); +const updatePostTextColumnsStmt = db.prepare('UPDATE posts SET post_text = ?, post_text_hash = ? WHERE id = ?'); + +const postsMissingKey = db.prepare(` + SELECT id, url + FROM posts + WHERE content_key IS NULL OR content_key = '' +`).all(); + +for (const entry of postsMissingKey) { + const normalizedUrl = normalizeFacebookPostUrl(entry.url); + const key = extractFacebookContentKey(normalizedUrl); + if (key) { + updateContentKeyStmt.run(key, entry.id); + } +} + +const postsMissingHash = db.prepare(` + SELECT id, post_text + FROM posts + WHERE post_text IS NOT NULL + AND TRIM(post_text) <> '' + AND (post_text_hash IS NULL OR post_text_hash = '') +`).all(); + +for (const entry of postsMissingHash) { + const normalizedText = normalizePostText(entry.post_text); + const hash = computePostTextHash(normalizedText); + updatePostTextColumnsStmt.run(normalizedText, hash, entry.id); +} + function parseCookies(header) { if (!header || typeof header !== 'string') { return {}; @@ -242,6 +292,30 @@ function normalizeCreatorName(value) { return trimmed.slice(0, 160); } +function normalizePostText(value) { + if (typeof value !== 'string') { + return null; + } + + let text = value.replace(/\s+/g, ' ').trim(); + if (!text) { + return null; + } + + if (text.length > MAX_POST_TEXT_LENGTH) { + text = text.slice(0, MAX_POST_TEXT_LENGTH); + } + + return text; +} + +function computePostTextHash(text) { + if (!text) { + return null; + } + return crypto.createHash('sha256').update(text, 'utf8').digest('hex'); +} + function normalizeFacebookPostUrl(rawValue) { if (typeof rawValue !== 'string') { return null; @@ -274,6 +348,10 @@ function normalizeFacebookPostUrl(rawValue) { return null; } + parsed.hostname = 'www.facebook.com'; + parsed.protocol = 'https:'; + parsed.port = ''; + const cleanedParams = new URLSearchParams(); parsed.searchParams.forEach((paramValue, paramKey) => { const lowerKey = paramKey.toLowerCase(); @@ -307,6 +385,88 @@ function normalizeFacebookPostUrl(rawValue) { return formatted.replace(/[?&]$/, ''); } +function extractFacebookContentKey(normalizedUrl) { + if (!normalizedUrl) { + return null; + } + + try { + const parsed = new URL(normalizedUrl); + const pathnameRaw = parsed.pathname || '/'; + const pathname = pathnameRaw.replace(/\/+$/, '') || '/'; + const lowerPath = pathname.toLowerCase(); + const params = parsed.searchParams; + + const reelMatch = lowerPath.match(/^\/reel\/([^/]+)/); + if (reelMatch) { + return `reel:${reelMatch[1]}`; + } + + const watchId = params.get('v') || params.get('video_id'); + if ((lowerPath === '/watch' || lowerPath === '/watch/') && watchId) { + return `video:${watchId}`; + } + if (lowerPath === '/video.php' && watchId) { + return `video:${watchId}`; + } + + const photoId = params.get('fbid'); + if ((lowerPath === '/photo.php' || lowerPath === '/photo') && photoId) { + return `photo:${photoId}`; + } + + const storyFbid = params.get('story_fbid'); + if (storyFbid) { + const ownerId = params.get('id') || params.get('gid') || params.get('group_id') || params.get('page_id') || ''; + return `story:${ownerId}:${storyFbid}`; + } + + const groupPostMatch = lowerPath.match(/^\/groups\/([^/]+)\/posts\/([^/]+)/); + if (groupPostMatch) { + return `group-post:${groupPostMatch[1]}:${groupPostMatch[2]}`; + } + + const groupPermalinkMatch = lowerPath.match(/^\/groups\/([^/]+)\/permalink\/([^/]+)/); + if (groupPermalinkMatch) { + return `group-post:${groupPermalinkMatch[1]}:${groupPermalinkMatch[2]}`; + } + + const pagePostMatch = lowerPath.match(/^\/([^/]+)\/posts\/([^/]+)/); + if (pagePostMatch) { + return `profile-post:${pagePostMatch[1]}:${pagePostMatch[2]}`; + } + + const pageVideoMatch = lowerPath.match(/^\/([^/]+)\/videos\/([^/]+)/); + if (pageVideoMatch) { + return `video:${pageVideoMatch[2]}`; + } + + const pagePhotoMatch = lowerPath.match(/^\/([^/]+)\/photos\/[^/]+\/([^/]+)/); + if (pagePhotoMatch) { + return `photo:${pagePhotoMatch[2]}`; + } + + if (lowerPath === '/' && storyFbid) { + const ownerId = params.get('id') || ''; + return `story:${ownerId}:${storyFbid}`; + } + + if ((lowerPath === '/permalink.php' || lowerPath === '/story.php') && storyFbid) { + const ownerId = params.get('id') || ''; + return `story:${ownerId}:${storyFbid}`; + } + + const sortedParams = Array.from(params.entries()) + .map(([key, value]) => `${key}=${value}`) + .sort() + .join('&'); + + return `generic:${lowerPath}?${sortedParams}`; + } catch (error) { + return `generic:${normalizedUrl}`; + } +} + function getRequiredProfiles(targetCount) { const count = clampTargetCount(targetCount); return Array.from({ length: count }, (_, index) => index + 1); @@ -393,6 +553,12 @@ db.exec(` target_count INTEGER NOT NULL, checked_count INTEGER DEFAULT 0, screenshot_path TEXT, + created_by_profile INTEGER, + created_by_name TEXT, + deadline_at DATETIME, + post_text TEXT, + post_text_hash TEXT, + content_key TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, last_change DATETIME DEFAULT CURRENT_TIMESTAMP ); @@ -415,17 +581,9 @@ db.exec(` `); db.exec(` - CREATE UNIQUE INDEX IF NOT EXISTS idx_post_urls_primary - ON post_urls(post_id) - WHERE is_primary = 1; + DROP INDEX IF EXISTS idx_post_urls_primary; `); -db.prepare(` - INSERT OR IGNORE INTO post_urls (post_id, url, is_primary) - SELECT id, url, 1 - FROM posts -`).run(); - db.exec(` CREATE TABLE IF NOT EXISTS checks ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -497,13 +655,6 @@ db.exec(` ON search_seen_posts(last_seen_at); `); -const ensureColumn = (table, column, definition) => { - const columns = db.prepare(`PRAGMA table_info(${table})`).all(); - if (!columns.some(col => col.name === column)) { - db.exec(`ALTER TABLE ${table} ADD COLUMN ${definition}`); - } -}; - ensureColumn('posts', 'checked_count', 'checked_count INTEGER DEFAULT 0'); ensureColumn('posts', 'screenshot_path', 'screenshot_path TEXT'); ensureColumn('posts', 'created_by_profile', 'created_by_profile INTEGER'); @@ -588,6 +739,8 @@ function normalizeExistingPostUrls() { try { db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(cleaned, row.id); + const updatedKey = extractFacebookContentKey(cleaned); + updateContentKeyStmt.run(updatedKey || null, row.id); updatedCount += 1; } catch (error) { if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { @@ -605,6 +758,42 @@ function normalizeExistingPostUrls() { normalizeExistingPostUrls(); +function normalizeExistingPostUrlMappings() { + const rows = db.prepare('SELECT id, url FROM post_urls').all(); + let updated = 0; + let removed = 0; + + for (const row of rows) { + const normalized = normalizeFacebookPostUrl(row.url); + if (!normalized) { + continue; + } + + if (normalized === row.url) { + continue; + } + + try { + db.prepare('UPDATE post_urls SET url = ? WHERE id = ?').run(normalized, row.id); + updated += 1; + } catch (error) { + if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { + db.prepare('DELETE FROM post_urls WHERE id = ?').run(row.id); + removed += 1; + } else { + console.warn(`Failed to normalize post_urls entry ${row.id}:`, error.message); + } + } + } + + if (updated || removed) { + console.log(`Normalized post_urls entries: updated ${updated}, removed ${removed}`); + } +} + +normalizeExistingPostUrlMappings(); +db.prepare('DELETE FROM post_urls WHERE url IN (SELECT url FROM posts)').run(); + function truncateString(value, maxLength) { if (typeof value !== 'string') { return value; @@ -1239,19 +1428,22 @@ function collectPostAlternateUrls(primaryUrl, candidates = []) { return []; } + const primaryKey = extractFacebookContentKey(normalizedPrimary); const normalized = collectNormalizedFacebookUrls(normalizedPrimary, candidates); - return normalized.filter(url => url !== normalizedPrimary); + + return normalized.filter((url) => { + if (url === normalizedPrimary) { + return false; + } + + const candidateKey = extractFacebookContentKey(url); + return candidateKey && candidateKey === primaryKey; + }); } const insertPostUrlStmt = db.prepare(` INSERT OR IGNORE INTO post_urls (post_id, url, is_primary) - VALUES (?, ?, ?) -`); - -const setPrimaryPostUrlStmt = db.prepare(` - UPDATE post_urls - SET is_primary = CASE WHEN url = ? THEN 1 ELSE 0 END - WHERE post_id = ? + VALUES (?, ?, 0) `); const selectPostByPrimaryUrlStmt = db.prepare('SELECT * FROM posts WHERE url = ?'); @@ -1268,9 +1460,9 @@ const selectAlternateUrlsForPostStmt = db.prepare(` SELECT url FROM post_urls WHERE post_id = ? - AND is_primary = 0 ORDER BY created_at ASC `); +const selectPostByTextHashStmt = db.prepare('SELECT * FROM posts WHERE post_text_hash = ?'); function storePostUrls(postId, primaryUrl, additionalUrls = []) { if (!postId || !primaryUrl) { @@ -1282,8 +1474,7 @@ function storePostUrls(postId, primaryUrl, additionalUrls = []) { return; } - insertPostUrlStmt.run(postId, normalizedPrimary, 1); - setPrimaryPostUrlStmt.run(normalizedPrimary, postId); + const primaryKey = extractFacebookContentKey(normalizedPrimary); if (Array.isArray(additionalUrls)) { for (const candidate of additionalUrls) { @@ -1291,6 +1482,16 @@ function storePostUrls(postId, primaryUrl, additionalUrls = []) { if (!normalized || normalized === normalizedPrimary) { continue; } + + const candidateKey = extractFacebookContentKey(normalized); + if (!candidateKey || candidateKey !== primaryKey) { + continue; + } + + const existingPostId = findPostIdByUrl(normalized); + if (existingPostId && existingPostId !== postId) { + continue; + } insertPostUrlStmt.run(postId, normalized, 0); } } @@ -1398,6 +1599,24 @@ function mapPostRow(post) { return null; } + let postContentKey = post.content_key; + if (!postContentKey) { + const normalizedUrl = normalizeFacebookPostUrl(post.url); + postContentKey = extractFacebookContentKey(normalizedUrl); + if (postContentKey) { + updateContentKeyStmt.run(postContentKey, post.id); + post.content_key = postContentKey; + } + } + + if (post.post_text && (!post.post_text_hash || !post.post_text_hash.trim())) { + const normalizedPostText = normalizePostText(post.post_text); + const hash = computePostTextHash(normalizedPostText); + post.post_text = normalizedPostText; + post.post_text_hash = hash; + updatePostTextColumnsStmt.run(normalizedPostText, hash, post.id); + } + const checks = db.prepare('SELECT id, profile_number, checked_at FROM checks WHERE post_id = ? ORDER BY checked_at ASC').all(post.id); const requiredProfiles = getRequiredProfiles(post.target_count); const { statuses, completedChecks } = buildProfileStatuses(requiredProfiles, checks); @@ -1467,7 +1686,10 @@ function mapPostRow(post) { created_by_profile_name: creatorProfile ? getProfileName(creatorProfile) : null, created_by_name: creatorName, deadline_at: post.deadline_at || null, - alternate_urls: alternateUrls + alternate_urls: alternateUrls, + post_text: post.post_text || null, + post_text_hash: post.post_text_hash || null, + content_key: post.content_key || postContentKey || null }; } @@ -1736,7 +1958,8 @@ app.post('/api/posts', (req, res) => { created_by_profile, created_by_name, profile_number, - deadline_at + deadline_at, + post_text } = req.body; const validatedTargetCount = validateTargetCount(typeof target_count === 'undefined' ? 1 : target_count); @@ -1754,6 +1977,31 @@ app.post('/api/posts', (req, res) => { const id = uuidv4(); + const normalizedPostText = normalizePostText(post_text); + const postTextHash = computePostTextHash(normalizedPostText); + const contentKey = extractFacebookContentKey(normalizedUrl); + const useTextHashDedup = normalizedPostText && normalizedPostText.length >= MIN_TEXT_HASH_LENGTH && postTextHash; + + if (useTextHashDedup) { + let existingByHash = selectPostByTextHashStmt.get(postTextHash); + if (existingByHash) { + const alternateCandidates = [normalizedUrl, ...alternateUrlsInput]; + const alternateUrls = collectPostAlternateUrls(existingByHash.url, alternateCandidates); + storePostUrls(existingByHash.id, existingByHash.url, alternateUrls); + + const cleanupSet = new Set([existingByHash.url, normalizedUrl, ...alternateUrls]); + removeSearchSeenEntries(Array.from(cleanupSet)); + + if (normalizedPostText && (!existingByHash.post_text || !existingByHash.post_text.trim())) { + updatePostTextColumnsStmt.run(normalizedPostText, postTextHash, existingByHash.id); + touchPost(existingByHash.id); + existingByHash = db.prepare('SELECT * FROM posts WHERE id = ?').get(existingByHash.id); + } + + return res.json(mapPostRow(existingByHash)); + } + } + let creatorProfile = sanitizeProfileNumber(created_by_profile); if (!creatorProfile) { creatorProfile = sanitizeProfileNumber(profile_number) || null; @@ -1770,10 +2018,35 @@ app.post('/api/posts', (req, res) => { const creatorDisplayName = normalizeCreatorName(created_by_name); const stmt = db.prepare(` - INSERT INTO posts (id, url, title, target_count, checked_count, screenshot_path, created_by_profile, created_by_name, deadline_at, last_change) - VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, CURRENT_TIMESTAMP) + INSERT INTO posts ( + id, + url, + title, + target_count, + checked_count, + screenshot_path, + created_by_profile, + created_by_name, + deadline_at, + post_text, + post_text_hash, + content_key, + last_change + ) + VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) `); - stmt.run(id, normalizedUrl, title || '', validatedTargetCount, creatorProfile, creatorDisplayName, normalizedDeadline); + stmt.run( + id, + normalizedUrl, + title || '', + validatedTargetCount, + creatorProfile, + creatorDisplayName, + normalizedDeadline, + normalizedPostText, + postTextHash, + contentKey || null + ); const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(id); @@ -1794,7 +2067,15 @@ app.post('/api/posts', (req, res) => { app.put('/api/posts/:postId', (req, res) => { try { const { postId } = req.params; - const { target_count, title, created_by_profile, created_by_name, deadline_at, url } = req.body || {}; + const { + target_count, + title, + created_by_profile, + created_by_name, + deadline_at, + url, + post_text + } = req.body || {}; const alternateUrlsInput = Array.isArray(req.body && req.body.alternate_urls) ? req.body.alternate_urls : []; const existingPost = db.prepare('SELECT * FROM posts WHERE id = ?').get(postId); @@ -1805,6 +2086,7 @@ app.put('/api/posts/:postId', (req, res) => { const updates = []; const params = []; let normalizedUrlForCleanup = null; + let updatedContentKey = null; if (typeof target_count !== 'undefined') { const validatedTargetCount = validateTargetCount(target_count); @@ -1856,6 +2138,19 @@ app.put('/api/posts/:postId', (req, res) => { updates.push('url = ?'); params.push(normalizedUrl); normalizedUrlForCleanup = normalizedUrl; + const newContentKey = extractFacebookContentKey(normalizedUrl); + updates.push('content_key = ?'); + params.push(newContentKey || null); + updatedContentKey = newContentKey || null; + } + + if (typeof post_text !== 'undefined') { + const normalizedPostText = normalizePostText(post_text); + const postTextHash = computePostTextHash(normalizedPostText); + updates.push('post_text = ?'); + params.push(normalizedPostText); + updates.push('post_text_hash = ?'); + params.push(postTextHash); } if (!updates.length) { @@ -2217,8 +2512,9 @@ app.patch('/api/posts/:postId', (req, res) => { return res.status(409).json({ error: 'URL already used by another post' }); } + const contentKey = extractFacebookContentKey(normalizedUrl); // Update URL - db.prepare('UPDATE posts SET url = ? WHERE id = ?').run(normalizedUrl, postId); + db.prepare('UPDATE posts SET url = ?, content_key = ? WHERE id = ?').run(normalizedUrl, contentKey || null, postId); const alternateCandidates = []; if (existingPost.url && existingPost.url !== normalizedUrl) { diff --git a/extension/content.js b/extension/content.js index 3dfe67d..157b666 100644 --- a/extension/content.js +++ b/extension/content.js @@ -6,6 +6,7 @@ const PROCESSED_ATTR = 'data-fb-tracker-processed'; const PENDING_ATTR = 'data-fb-tracker-pending'; const DIALOG_ROOT_SELECTOR = '[role="dialog"], [data-pagelet*="Modal"], [data-pagelet="StoriesRecentStoriesFeedSection"]'; const API_URL = `${API_BASE_URL}/api`; +const WEBAPP_BASE_URL = API_BASE_URL.replace(/\/+$/, ''); const MAX_SELECTION_LENGTH = 5000; const postSelectionCache = new WeakMap(); const LAST_SELECTION_MAX_AGE = 5000; @@ -148,6 +149,28 @@ const aiCredentialCache = { console.log(`[FB Tracker v${EXTENSION_VERSION}] Extension loaded, API URL:`, API_URL); +function ensureTrackerActionsContainer(container) { + if (!container) { + return null; + } + + let actionsContainer = container.querySelector('.fb-tracker-actions-end'); + if (actionsContainer && actionsContainer.isConnected) { + return actionsContainer; + } + + actionsContainer = document.createElement('div'); + actionsContainer.className = 'fb-tracker-actions-end'; + actionsContainer.style.cssText = ` + margin-left: auto; + display: inline-flex; + align-items: center; + gap: 8px; + `; + container.appendChild(actionsContainer); + return actionsContainer; +} + function backendFetch(url, options = {}) { const config = { ...options, @@ -743,6 +766,15 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options = createdByName = extractAuthorName(options.postElement) || null; } + let postText = null; + if (options && options.postElement) { + try { + postText = extractPostText(options.postElement) || null; + } catch (error) { + console.debug('[FB Tracker] Failed to extract post text:', error); + } + } + let deadlineIso = null; if (options && typeof options.deadline === 'string' && options.deadline.trim()) { const parsedDeadline = new Date(options.deadline.trim()); @@ -778,6 +810,10 @@ async function addPostToTracking(postUrl, targetCount, profileNumber, options = payload.deadline_at = deadlineIso; } + if (postText) { + payload.post_text = postText; + } + const response = await backendFetch(`${API_URL}/posts`, { method: 'POST', headers: { @@ -1941,6 +1977,61 @@ async function renderTrackedStatus({ container.innerHTML = statusHtml; + if (postData.id) { + const actionsContainer = ensureTrackerActionsContainer(container); + if (actionsContainer) { + const webAppUrl = (() => { + try { + const baseUrl = `${WEBAPP_BASE_URL}/`; + const url = new URL('', baseUrl); + url.searchParams.set('tab', 'all'); + url.searchParams.set('postId', String(postData.id)); + if (postData.url) { + url.searchParams.set('postUrl', postData.url); + } + return url.toString(); + } catch (error) { + console.debug('[FB Tracker] Failed to build WebApp URL, falling back to base:', error); + return `${WEBAPP_BASE_URL}/?tab=all`; + } + })(); + + let webAppLink = actionsContainer.querySelector('.fb-tracker-webapp-link'); + if (!webAppLink) { + webAppLink = document.createElement('a'); + webAppLink.className = 'fb-tracker-webapp-link'; + webAppLink.target = '_blank'; + webAppLink.rel = 'noopener noreferrer'; + webAppLink.setAttribute('aria-label', 'In der Webapp anzeigen'); + webAppLink.title = 'In der Webapp anzeigen'; + webAppLink.textContent = '📋'; + webAppLink.style.cssText = ` + text-decoration: none; + font-size: 18px; + line-height: 1; + padding: 4px 6px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 6px; + color: inherit; + transition: background-color 0.2s ease, transform 0.2s ease; + cursor: pointer; + `; + webAppLink.addEventListener('mouseenter', () => { + webAppLink.style.backgroundColor = 'rgba(0, 0, 0, 0.08)'; + webAppLink.style.transform = 'translateY(-1px)'; + }); + webAppLink.addEventListener('mouseleave', () => { + webAppLink.style.backgroundColor = 'transparent'; + webAppLink.style.transform = 'translateY(0)'; + }); + actionsContainer.insertBefore(webAppLink, actionsContainer.firstChild); + } + webAppLink.href = webAppUrl; + } + } + await addAICommentButton(container, postElement); const checkBtn = container.querySelector('.fb-tracker-check-btn'); @@ -3716,6 +3807,11 @@ async function addAICommentButton(container, postElement) { return; } + const actionsContainer = ensureTrackerActionsContainer(container); + if (!actionsContainer) { + return; + } + const encodedPostUrl = container && container.getAttribute('data-post-url') ? container.getAttribute('data-post-url') : null; @@ -3723,7 +3819,6 @@ async function addAICommentButton(container, postElement) { const wrapper = document.createElement('div'); wrapper.className = 'fb-tracker-ai-wrapper'; wrapper.style.cssText = ` - margin-left: auto; position: relative; display: inline-flex; align-items: stretch; @@ -3792,7 +3887,7 @@ async function addAICommentButton(container, postElement) { wrapper.appendChild(button); wrapper.appendChild(dropdownButton); wrapper.appendChild(dropdown); - container.appendChild(wrapper); + actionsContainer.appendChild(wrapper); const baseButtonText = button.textContent; diff --git a/tracker.db b/tracker.db new file mode 100644 index 0000000..4d62a3f Binary files /dev/null and b/tracker.db differ diff --git a/web/app.js b/web/app.js index f08fa10..a5cb93d 100644 --- a/web/app.js +++ b/web/app.js @@ -1,17 +1,35 @@ const API_URL = 'https://fb.srv.medeba-media.de/api'; -// Check if we should redirect to dashboard -(function checkViewRouting() { - const params = new URLSearchParams(window.location.search); - const view = params.get('view'); - if (view === 'dashboard') { - // Remove view parameter and keep other params - params.delete('view'); - const remainingParams = params.toString(); - window.location.href = 'dashboard.html' + (remainingParams ? '?' + remainingParams : ''); +let initialViewParam = null; + +// Normalize incoming routing parameters without leaving the index view +(function normalizeViewRouting() { + try { + const params = new URLSearchParams(window.location.search); + const view = params.get('view'); + if (!view) { + return; + } + + if (view === 'dashboard') { + initialViewParam = view; + params.delete('view'); + const remaining = params.toString(); + const newUrl = `${window.location.pathname}${remaining ? `?${remaining}` : ''}${window.location.hash}`; + window.history.replaceState({}, document.title, newUrl); + } + } catch (error) { + console.warn('Konnte view-Parameter nicht verarbeiten:', error); } })(); +let focusPostIdParam = null; +let focusPostUrlParam = null; +let focusNormalizedUrl = ''; +let focusHandled = false; +let initialTabOverride = null; +let focusTabAdjusted = null; + let currentProfile = 1; let currentTab = 'pending'; let posts = []; @@ -89,6 +107,30 @@ const BOOKMARK_WINDOW_DAYS = 28; const DEFAULT_BOOKMARKS = []; const BOOKMARK_SUFFIXES = ['Gewinnspiel', 'gewinnen', 'verlosen']; +function initializeFocusParams() { + try { + const params = new URLSearchParams(window.location.search); + const postIdParam = params.get('postId'); + const postUrlParam = params.get('postUrl'); + + if (postIdParam && postIdParam.trim()) { + focusPostIdParam = postIdParam.trim(); + initialTabOverride = initialTabOverride || 'all'; + } + + if (postUrlParam && postUrlParam.trim()) { + focusPostUrlParam = postUrlParam.trim(); + const normalized = normalizeFacebookPostUrl(focusPostUrlParam); + focusNormalizedUrl = normalized || focusPostUrlParam; + initialTabOverride = initialTabOverride || 'all'; + } + focusHandled = false; + focusTabAdjusted = null; + } catch (error) { + console.warn('Konnte Fokus-Parameter nicht verarbeiten:', error); + } +} + let autoRefreshTimer = null; let autoRefreshSettings = { enabled: true, @@ -882,18 +924,28 @@ function setTab(tab, { updateUrl = true } = {}) { } function initializeTabFromUrl() { + let tabResolved = false; try { const params = new URLSearchParams(window.location.search); const tabParam = params.get('tab'); if (tabParam === 'all' || tabParam === 'pending' || tabParam === 'expired') { currentTab = tabParam; + tabResolved = true; + } else if (initialTabOverride) { + currentTab = initialTabOverride; + tabResolved = true; + } else if (initialViewParam === 'dashboard') { + currentTab = 'pending'; + tabResolved = true; } } catch (error) { console.warn('Konnte Tab-Parameter nicht auslesen:', error); } updateTabButtons(); - updateTabInUrl(); + if (tabResolved) { + updateTabInUrl(); + } } function normalizeDeadlineInput(value) { @@ -2225,6 +2277,100 @@ async function normalizeLoadedPostUrls() { return changed; } +function doesPostMatchFocus(post) { + if (!post) { + return false; + } + if (focusPostIdParam && String(post.id) === focusPostIdParam) { + return true; + } + if (focusNormalizedUrl && post.url) { + const candidateNormalized = normalizeFacebookPostUrl(post.url) || post.url; + return candidateNormalized === focusNormalizedUrl; + } + return false; +} + +function resolveFocusTargetInfo(items) { + if (!Array.isArray(items) || (!focusPostIdParam && !focusNormalizedUrl)) { + return { index: -1, post: null }; + } + const index = items.findIndex(({ post }) => doesPostMatchFocus(post)); + return { + index, + post: index !== -1 && items[index] ? items[index].post : null + }; +} + +function clearFocusParamsFromUrl() { + try { + const url = new URL(window.location.href); + let changed = false; + if (url.searchParams.has('postId')) { + url.searchParams.delete('postId'); + changed = true; + } + if (url.searchParams.has('postUrl')) { + url.searchParams.delete('postUrl'); + changed = true; + } + if (changed) { + const newQuery = url.searchParams.toString(); + const newUrl = `${url.pathname}${newQuery ? `?${newQuery}` : ''}${url.hash}`; + window.history.replaceState({}, document.title, newUrl); + } + } catch (error) { + console.warn('Konnte Fokus-Parameter nicht aus URL entfernen:', error); + } +} + +function highlightPostCard(post) { + if (!post || focusHandled) { + return; + } + + const card = document.getElementById(`post-${post.id}`); + if (!card) { + return; + } + + card.classList.add('post-card--highlight'); + + const hadTabIndex = card.hasAttribute('tabindex'); + if (!hadTabIndex) { + card.setAttribute('tabindex', '-1'); + card.dataset.fbTrackerTempTabindex = '1'; + } + + requestAnimationFrame(() => { + try { + card.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } catch (error) { + console.warn('Konnte Karte nicht scrollen:', error); + } + try { + card.focus({ preventScroll: true }); + } catch (error) { + // ignore focus errors + } + }); + + window.setTimeout(() => { + card.classList.remove('post-card--highlight'); + if (card.dataset.fbTrackerTempTabindex === '1') { + card.removeAttribute('tabindex'); + delete card.dataset.fbTrackerTempTabindex; + } + }, 4000); + + focusPostIdParam = null; + focusPostUrlParam = null; + focusNormalizedUrl = ''; + focusHandled = true; + focusTabAdjusted = null; + clearFocusParamsFromUrl(); +} + // Render posts function renderPosts() { hideLoading(); @@ -2245,6 +2391,9 @@ function renderPosts() { })); const sortedItems = [...postItems].sort(comparePostItems); + const focusCandidateEntry = (!focusHandled && (focusPostIdParam || focusNormalizedUrl)) + ? sortedItems.find((item) => doesPostMatchFocus(item.post)) + : null; let filteredItems = sortedItems; @@ -2275,6 +2424,38 @@ function renderPosts() { }); } + if (!focusHandled && focusCandidateEntry && !searchActive) { + const candidateVisibleInCurrentTab = filteredItems.some(({ post }) => doesPostMatchFocus(post)); + if (!candidateVisibleInCurrentTab) { + let desiredTab = 'all'; + if (focusCandidateEntry.status.isExpired || focusCandidateEntry.status.isComplete) { + desiredTab = 'expired'; + } else if (focusCandidateEntry.status.canCurrentProfileCheck && !focusCandidateEntry.status.isExpired && !focusCandidateEntry.status.isComplete) { + desiredTab = 'pending'; + } + + if (currentTab !== desiredTab && focusTabAdjusted !== desiredTab) { + focusTabAdjusted = desiredTab; + setTab(desiredTab); + return; + } + + if (desiredTab !== 'all' && currentTab !== 'all' && focusTabAdjusted !== 'all') { + focusTabAdjusted = 'all'; + setTab('all'); + return; + } + } + } + + const focusTargetInfo = resolveFocusTargetInfo(filteredItems); + if (focusTargetInfo.index !== -1) { + const requiredVisible = focusTargetInfo.index + 1; + if (requiredVisible > getVisibleCount(currentTab)) { + setVisibleCount(currentTab, requiredVisible); + } + } + updateFilteredCount(currentTab, filteredItems.length); const visibleCount = Math.min(filteredItems.length, getVisibleCount(currentTab)); @@ -2346,6 +2527,10 @@ function renderPosts() { observeLoadMoreElement(loadMoreContainer, currentTab); } + + if (!focusHandled && focusTargetInfo.index !== -1 && focusTargetInfo.post) { + requestAnimationFrame(() => highlightPostCard(focusTargetInfo.post)); + } } function attachPostEventHandlers(post, status) { @@ -3306,6 +3491,7 @@ window.addEventListener('resize', () => { // Initialize initializeBookmarks(); loadAutoRefreshSettings(); +initializeFocusParams(); initializeTabFromUrl(); loadSortMode(); resetManualPostForm(); diff --git a/web/style.css b/web/style.css index 223e01e..8db9cce 100644 --- a/web/style.css +++ b/web/style.css @@ -466,6 +466,31 @@ h1 { border-left: 4px solid #059669; } +.post-card--highlight { + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.1), + 0 0 0 4px rgba(102, 126, 234, 0.35); + animation: post-card-highlight-pulse 1.4s ease-in-out 2; +} + +@keyframes post-card-highlight-pulse { + 0% { + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.1), + 0 0 0 0 rgba(102, 126, 234, 0.45); + } + 50% { + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.1), + 0 0 0 8px rgba(102, 126, 234, 0.2); + } + 100% { + box-shadow: + 0 1px 2px rgba(0, 0, 0, 0.1), + 0 0 0 0 rgba(102, 126, 234, 0.45); + } +} + .post-header { display: flex; justify-content: space-between;