diff --git a/extension/background.js b/extension/background.js index b5b66c9..8f0c9c6 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1,25 +1,51 @@ // Background script for service worker // Currently minimal, can be extended for additional functionality -chrome.runtime.onInstalled.addListener(() => { - console.log('Facebook Post Tracker extension installed'); - - // Set default profile if not set - chrome.storage.sync.get(['profileNumber'], (result) => { - if (!result.profileNumber) { - chrome.storage.sync.set({ profileNumber: 1 }); +function setupContextMenus() { + chrome.contextMenus.removeAll(() => { + if (chrome.runtime.lastError) { + console.warn('FB Tracker: context menu reset warning:', chrome.runtime.lastError.message); } - }); - // Create context menu for manual post parsing - chrome.contextMenus.create({ - id: 'fb-tracker-reparse', - title: 'FB Tracker: Post neu parsen', - contexts: ['all'], - documentUrlPatterns: ['*://*.facebook.com/*'] + chrome.contextMenus.create({ + id: 'fb-tracker-reparse', + title: 'FB Tracker: Post neu parsen', + contexts: ['all'], + documentUrlPatterns: ['*://*.facebook.com/*'] + }); + + chrome.contextMenus.create({ + id: 'fb-tracker-selection-ai', + title: 'FB Tracker: Auswahl mit AI beantworten', + contexts: ['selection'], + documentUrlPatterns: ['*://*.facebook.com/*'] + }); }); +} + +chrome.runtime.onInstalled.addListener((details) => { + console.log('Facebook Post Tracker extension installed/updated'); + + if (details.reason === 'install') { + chrome.storage.sync.get(['profileNumber'], (result) => { + if (!result.profileNumber) { + chrome.storage.sync.set({ profileNumber: 1 }); + } + }); + } + + setupContextMenus(); }); +if (chrome.runtime.onStartup) { + chrome.runtime.onStartup.addListener(() => { + setupContextMenus(); + }); +} + +// Ensure menus exist after service worker restarts +setupContextMenus(); + chrome.contextMenus.onClicked.addListener((info, tab) => { if (info.menuItemId === 'fb-tracker-reparse') { chrome.tabs.sendMessage(tab.id, { @@ -27,6 +53,16 @@ chrome.contextMenus.onClicked.addListener((info, tab) => { x: info.pageX || 0, y: info.pageY || 0 }); + } else if (info.menuItemId === 'fb-tracker-selection-ai') { + chrome.tabs.sendMessage(tab.id, { + type: 'generateSelectionAI', + selectionText: info.selectionText || '' + }, (response) => { + if (chrome.runtime.lastError) { + console.warn('FB Tracker selection AI error:', chrome.runtime.lastError.message); + } + return true; + }); } }); diff --git a/extension/content.js b/extension/content.js index 8126461..3dc7945 100644 --- a/extension/content.js +++ b/extension/content.js @@ -18,6 +18,7 @@ const sessionSearchRecordedUrls = new Set(); const sessionSearchInfoCache = new Map(); const trackerElementsByPost = new WeakMap(); +const postAdditionalNotes = new WeakMap(); const REELS_PATH_PREFIX = '/reel/'; @@ -2614,6 +2615,11 @@ document.addEventListener('contextmenu', (event) => { // Listen for manual reparse command chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message && message.type === 'generateSelectionAI') { + handleSelectionAIRequest(message.selectionText || '', sendResponse); + return true; + } + if (message && message.type === 'reparsePost') { console.log('[FB Tracker] Manual reparse triggered'); @@ -2752,6 +2758,38 @@ function showToast(message, type = 'info') { }, 3000); } +async function copyTextToClipboard(text) { + if (typeof text !== 'string') { + return false; + } + + if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (error) { + console.warn('[FB Tracker] navigator.clipboard.writeText failed:', error); + } + } + + try { + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.style.position = 'fixed'; + textarea.style.top = '-999px'; + textarea.style.left = '-999px'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const success = document.execCommand('copy'); + document.body.removeChild(textarea); + return success; + } catch (error) { + console.warn('[FB Tracker] execCommand copy fallback failed:', error); + return false; + } +} + /** * Extract post text from a Facebook post element */ @@ -3328,6 +3366,38 @@ async function generateAIComment(postText, profileNumber, options = {}) { } } +async function handleSelectionAIRequest(selectionText, sendResponse) { + try { + const normalizedSelection = normalizeSelectionText(selectionText); + if (!normalizedSelection) { + showToast('Keine gültige Auswahl gefunden', 'error'); + sendResponse({ success: false, error: 'Keine gültige Auswahl gefunden' }); + return; + } + + showToast('AI verarbeitet Auswahl...', 'info'); + + const profileNumber = await getProfileNumber(); + const comment = await generateAIComment(normalizedSelection, profileNumber, {}); + + if (!comment) { + throw new Error('Keine Antwort vom AI-Dienst erhalten'); + } + + const copied = await copyTextToClipboard(comment); + if (!copied) { + throw new Error('Antwort konnte nicht in die Zwischenablage kopiert werden'); + } + + showToast('AI-Antwort in die Zwischenablage kopiert', 'success'); + sendResponse({ success: true, comment }); + } catch (error) { + console.error('[FB Tracker] Selection AI error:', error); + showToast(`❌ ${error.message || 'Fehler bei AI-Anfrage'}`, 'error'); + sendResponse({ success: false, error: error.message || 'Unbekannter Fehler' }); + } +} + /** * Check if AI is enabled */ @@ -3419,9 +3489,6 @@ async function addAICommentButton(container, postElement) { dropdown.className = 'fb-tracker-ai-dropdown'; dropdown.style.cssText = ` display: none; - position: absolute; - right: 0; - top: calc(100% + 6px); min-width: 220px; background: #ffffff; border-radius: 8px; @@ -3435,6 +3502,75 @@ async function addAICommentButton(container, postElement) { wrapper.appendChild(dropdown); container.appendChild(wrapper); + const baseButtonText = button.textContent; + + const resolvePostContexts = () => { + const contextCandidate = container.closest('div[aria-posinset], article[role="article"], article, div[role="complementary"]'); + const normalizedContext = contextCandidate ? ensurePrimaryPostElement(contextCandidate) : null; + const fallbackContext = postElement ? ensurePrimaryPostElement(postElement) : null; + const postContext = normalizedContext || fallbackContext || contextCandidate || postElement || container; + return { postContext, contextCandidate, fallbackContext, normalizedContext }; + }; + + const resolvePostContext = () => resolvePostContexts().postContext; + + const getAdditionalNote = () => { + const context = resolvePostContext(); + return context ? (postAdditionalNotes.get(context) || '') : ''; + }; + + let notePreviewElement = null; + let noteClearButton = null; + + const truncateNoteForPreview = (note) => { + if (!note) { + return ''; + } + return note.length > 120 ? `${note.slice(0, 117)}…` : note; + }; + + const updateNoteIndicator = () => { + const note = getAdditionalNote(); + const hasNote = note.trim().length > 0; + button.dataset.aiOriginalText = hasNote ? `${baseButtonText} ✎` : baseButtonText; + if ((button.dataset.aiState || 'idle') === 'idle' && !button._aiContext) { + button.textContent = button.dataset.aiOriginalText; + } + button.title = hasNote + ? 'Generiere automatisch einen passenden Kommentar (mit Zusatzinfo)' + : 'Generiere automatisch einen passenden Kommentar'; + }; + + const updateNotePreview = () => { + updateNoteIndicator(); + if (notePreviewElement) { + const note = getAdditionalNote(); + notePreviewElement.textContent = note + ? `Aktuelle Zusatzinfo: ${truncateNoteForPreview(note)}` + : 'Keine Zusatzinfo gesetzt'; + if (noteClearButton) { + const hasNote = note.trim().length > 0; + noteClearButton.disabled = !hasNote; + noteClearButton.style.opacity = hasNote ? '1' : '0.6'; + noteClearButton.style.cursor = hasNote ? 'pointer' : 'default'; + } + } + }; + + const setAdditionalNote = (value) => { + const context = resolvePostContext(); + if (!context) { + return; + } + const trimmed = (value || '').trim(); + if (trimmed) { + postAdditionalNotes.set(context, trimmed); + } else { + postAdditionalNotes.delete(context); + } + updateNotePreview(); + }; + button.addEventListener('mouseenter', () => { if ((button.dataset.aiState || 'idle') === 'idle') { button.style.transform = 'translateY(-2px)'; @@ -3470,10 +3606,8 @@ async function addAICommentButton(container, postElement) { }); button.addEventListener('pointerdown', () => { - const contextElement = container.closest('div[aria-posinset], article[role="article"], article, div[role="complementary"]'); - const normalized = contextElement ? ensurePrimaryPostElement(contextElement) : null; - const fallbackNormalized = postElement ? ensurePrimaryPostElement(postElement) : null; - const target = normalized || fallbackNormalized || contextElement || postElement || container; + const context = resolvePostContext(); + const target = context || postElement || container; cacheSelectionForPost(target); }); @@ -3481,6 +3615,32 @@ async function addAICommentButton(container, postElement) { button.dataset.aiOriginalText = button.textContent; let dropdownOpen = false; + let dropdownPortalParent = null; + + const resolveDropdownPortalParent = () => { + if (dropdownPortalParent && dropdownPortalParent.isConnected) { + return dropdownPortalParent; + } + const candidate = document.body || document.documentElement; + dropdownPortalParent = candidate; + return dropdownPortalParent; + }; + + const mountDropdownInPortal = () => { + const portalParent = resolveDropdownPortalParent(); + if (!portalParent) { + return; + } + if (dropdown.parentElement !== portalParent) { + portalParent.appendChild(dropdown); + } + }; + + const restoreDropdownToWrapper = () => { + if (dropdown.parentElement !== wrapper) { + wrapper.appendChild(dropdown); + } + }; const closeDropdown = () => { if (!dropdownOpen) { @@ -3497,10 +3657,18 @@ async function addAICommentButton(container, postElement) { dropdownButton.style.transform = 'translateY(0)'; button.style.boxShadow = 'none'; dropdownButton.style.boxShadow = 'none'; + dropdown.style.position = ''; + dropdown.style.top = ''; + dropdown.style.left = ''; + dropdown.style.maxHeight = ''; + dropdown.style.overflowY = ''; + restoreDropdownToWrapper(); + window.removeEventListener('scroll', repositionDropdown, true); + window.removeEventListener('resize', repositionDropdown); }; const handleOutsideClick = (event) => { - if (!wrapper.contains(event.target)) { + if (!wrapper.contains(event.target) && !dropdown.contains(event.target)) { closeDropdown(); } }; @@ -3518,82 +3686,187 @@ async function addAICommentButton(container, postElement) { loading.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #555;'; dropdown.appendChild(loading); + const appendNoteUI = () => { + noteClearButton = null; + const noteSection = document.createElement('div'); + noteSection.style.cssText = 'padding: 10px 14px; display: flex; flex-direction: column; gap: 6px;'; + + notePreviewElement = document.createElement('div'); + notePreviewElement.style.cssText = 'font-size: 12px; color: #65676b; white-space: normal;'; + noteSection.appendChild(notePreviewElement); + + const buttonsRow = document.createElement('div'); + buttonsRow.style.cssText = 'display: flex; gap: 8px; flex-wrap: wrap;'; + + const editButton = document.createElement('button'); + editButton.type = 'button'; + editButton.textContent = 'Zusatzinfo bearbeiten'; + editButton.style.cssText = 'padding: 6px 10px; border-radius: 6px; border: 1px solid #ccd0d5; background: #fff; cursor: pointer; font-size: 12px; font-weight: 600; color: #1d2129;'; + editButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + const existingNote = getAdditionalNote(); + const input = window.prompt('Zusatzinfo für den AI-Prompt eingeben:', existingNote); + if (input === null) { + return; + } + const trimmed = (input || '').trim(); + setAdditionalNote(trimmed); + if (trimmed) { + showToast('Zusatzinfo gespeichert', 'success'); + } else { + showToast('Zusatzinfo entfernt', 'success'); + } + }); + buttonsRow.appendChild(editButton); + + const clearButton = document.createElement('button'); + clearButton.type = 'button'; + clearButton.textContent = 'Zurücksetzen'; + clearButton.style.cssText = 'padding: 6px 10px; border-radius: 6px; border: 1px solid #ccd0d5; background: #fff; cursor: pointer; font-size: 12px; font-weight: 600; color: #a11900;'; + clearButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + if (!getAdditionalNote()) { + return; + } + setAdditionalNote(''); + showToast('Zusatzinfo entfernt', 'success'); + }); + buttonsRow.appendChild(clearButton); + noteClearButton = clearButton; + + noteSection.appendChild(buttonsRow); + dropdown.appendChild(noteSection); + + updateNotePreview(); + }; + try { const credentials = await fetchActiveAICredentials(); dropdown.innerHTML = ''; + appendNoteUI(); if (!credentials || credentials.length === 0) { const empty = document.createElement('div'); empty.textContent = 'Keine aktiven AI-Anbieter gefunden'; empty.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #888;'; dropdown.appendChild(empty); - return; - } + } else { + const divider = document.createElement('div'); + divider.style.cssText = 'border-top: 1px solid #e4e6eb; margin: 6px 0;'; + dropdown.appendChild(divider); - credentials.forEach((credential) => { - const option = document.createElement('button'); - option.type = 'button'; - option.className = 'fb-tracker-ai-option'; - option.style.cssText = ` - width: 100%; - padding: 8px 14px; - background: transparent; - border: none; - text-align: left; - cursor: pointer; - font-size: 13px; - display: flex; - flex-direction: column; - gap: 2px; - `; + credentials.forEach((credential) => { + const option = document.createElement('button'); + option.type = 'button'; + option.className = 'fb-tracker-ai-option'; + option.style.cssText = ` + width: 100%; + padding: 8px 14px; + background: transparent; + border: none; + text-align: left; + cursor: pointer; + font-size: 13px; + display: flex; + flex-direction: column; + gap: 2px; + `; - option.addEventListener('mouseenter', () => { - option.style.background = '#f0f2f5'; - }); + option.addEventListener('mouseenter', () => { + option.style.background = '#f0f2f5'; + }); - option.addEventListener('mouseleave', () => { - option.style.background = 'transparent'; - }); + option.addEventListener('mouseleave', () => { + option.style.background = 'transparent'; + }); - const label = document.createElement('span'); - label.textContent = formatAICredentialLabel(credential); - label.style.cssText = 'font-weight: 600; color: #1d2129;'; + const label = document.createElement('span'); + label.textContent = formatAICredentialLabel(credential); + label.style.cssText = 'font-weight: 600; color: #1d2129;'; - const metaParts = []; - if (credential.provider) { - metaParts.push(`Provider: ${credential.provider}`); - } - if (credential.model) { - metaParts.push(`Modell: ${credential.model}`); - } - - if (metaParts.length > 0) { - const meta = document.createElement('span'); - meta.textContent = metaParts.join(' · '); - meta.style.cssText = 'font-size: 12px; color: #65676b;'; - option.appendChild(label); - option.appendChild(meta); - } else { - option.appendChild(label); - } - - option.addEventListener('click', () => { - closeDropdown(); - if ((button.dataset.aiState || 'idle') === 'idle') { - cacheSelectionForPost(postElement); - startAIFlow(credential.id); + const metaParts = []; + if (credential.provider) { + metaParts.push(`Provider: ${credential.provider}`); + } + if (credential.model) { + metaParts.push(`Modell: ${credential.model}`); } - }); - dropdown.appendChild(option); - }); + if (metaParts.length > 0) { + const meta = document.createElement('span'); + meta.textContent = metaParts.join(' · '); + meta.style.cssText = 'font-size: 12px; color: #65676b;'; + option.appendChild(label); + option.appendChild(meta); + } else { + option.appendChild(label); + } + + option.addEventListener('click', () => { + closeDropdown(); + if ((button.dataset.aiState || 'idle') === 'idle') { + cacheSelectionForPost(postElement); + startAIFlow(credential.id); + } + }); + + dropdown.appendChild(option); + }); + } } catch (error) { dropdown.innerHTML = ''; + appendNoteUI(); const errorItem = document.createElement('div'); errorItem.textContent = error.message || 'AI-Anbieter konnten nicht geladen werden'; errorItem.style.cssText = 'padding: 8px 14px; font-size: 13px; color: #c0392b; white-space: normal;'; dropdown.appendChild(errorItem); } + }; + + const positionDropdown = () => { + if (!dropdownOpen) { + return; + } + + mountDropdownInPortal(); + dropdown.style.position = 'fixed'; + dropdown.style.maxHeight = `${Math.max(200, Math.floor(window.innerHeight * 0.6))}px`; + dropdown.style.overflowY = 'auto'; + + const rect = wrapper.getBoundingClientRect(); + const dropdownRect = dropdown.getBoundingClientRect(); + const margin = 8; + + let top = rect.top - dropdownRect.height - margin; + if (top < margin) { + top = rect.bottom + margin; + } + + const viewportPadding = 8; + let left = rect.right - dropdownRect.width; + if (left < viewportPadding) { + left = viewportPadding; + } + const maxLeft = window.innerWidth - dropdownRect.width - viewportPadding; + if (left > maxLeft) { + left = Math.max(viewportPadding, maxLeft); + } + + const maxTop = window.innerHeight - dropdownRect.height - margin; + if (top > maxTop) { + top = Math.max(viewportPadding, maxTop); + } + + dropdown.style.top = `${top}px`; + dropdown.style.left = `${left}px`; + }; + + const repositionDropdown = () => { + if (dropdownOpen) { + positionDropdown(); + } }; const toggleDropdown = async () => { @@ -3609,12 +3882,16 @@ async function addAICommentButton(container, postElement) { dropdownOpen = true; wrapper.classList.add('fb-tracker-ai-wrapper--open'); dropdownButton.textContent = '▴'; + mountDropdownInPortal(); dropdown.style.display = 'block'; dropdownButton.setAttribute('aria-expanded', 'true'); document.addEventListener('click', handleOutsideClick, true); document.addEventListener('keydown', handleKeydown, true); await renderDropdownItems(); + positionDropdown(); + window.addEventListener('scroll', repositionDropdown, true); + window.addEventListener('resize', repositionDropdown); }; dropdownButton.addEventListener('click', (event) => { @@ -3708,10 +3985,8 @@ async function addAICommentButton(container, postElement) { button.textContent = '⏳ Generiere...'; try { - const contextCandidate = container.closest('div[aria-posinset], article[role="article"], article, div[role="complementary"]'); - const normalizedContext = contextCandidate ? ensurePrimaryPostElement(contextCandidate) : null; - const fallbackContext = postElement ? ensurePrimaryPostElement(postElement) : null; - const postContext = normalizedContext || fallbackContext || contextCandidate || postElement || container; + const contexts = resolvePostContexts(); + const { postContext, contextCandidate, fallbackContext } = contexts; const selectionKeys = []; if (postContext) { @@ -3723,6 +3998,12 @@ async function addAICommentButton(container, postElement) { if (contextCandidate && contextCandidate !== postContext && contextCandidate !== postElement) { selectionKeys.push(contextCandidate); } + if (fallbackContext + && fallbackContext !== postContext + && fallbackContext !== postElement + && fallbackContext !== contextCandidate) { + selectionKeys.push(fallbackContext); + } const resolveRecentSelection = () => { for (const key of selectionKeys) { @@ -3775,15 +4056,20 @@ async function addAICommentButton(container, postElement) { throw new Error('Konnte Post-Text nicht extrahieren'); } - selectionKeys.forEach((key) => { - if (key) { - postSelectionCache.delete(key); - } - }); + selectionKeys.forEach((key) => { + if (key) { + postSelectionCache.delete(key); + } + }); - throwIfCancelled(); + const additionalNote = postContext ? (postAdditionalNotes.get(postContext) || '') : ''; + if (additionalNote) { + postText = `${postText}\n\nZusatzinfo:\n${additionalNote}`; + } - console.log('[FB Tracker] Generating AI comment for:', postText.substring(0, 100)); + throwIfCancelled(); + + console.log('[FB Tracker] Generating AI comment for:', postText.substring(0, 100)); const profileNumber = await getProfileNumber(); diff --git a/extension/manifest.json b/extension/manifest.json index 9b2c5fe..9f3353c 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -7,7 +7,8 @@ "storage", "activeTab", "tabs", - "contextMenus" + "contextMenus", + "clipboardWrite" ], "host_permissions": [ "", diff --git a/extension/popup.js b/extension/popup.js index ea186cc..f3874b3 100644 --- a/extension/popup.js +++ b/extension/popup.js @@ -77,18 +77,19 @@ async function initProfileSelect() { initProfileSelect(); -chrome.storage.sync.get(['debugLoggingEnabled'], (result) => { - const enabled = Boolean(result && result.debugLoggingEnabled); - debugToggle.checked = enabled; -}); - -debugToggle.addEventListener('change', () => { - const enabled = debugToggle.checked; - chrome.storage.sync.set({ debugLoggingEnabled: enabled }, () => { - updateStatus(`Debug-Logging ${enabled ? 'aktiviert' : 'deaktiviert'}`, true); - reloadFacebookTabs(); +if (debugToggle) { + chrome.storage.sync.get(['debugLoggingEnabled'], (result) => { + const enabled = Boolean(result && result.debugLoggingEnabled); + debugToggle.checked = enabled; }); -}); + + debugToggle.addEventListener('change', () => { + const enabled = debugToggle.checked; + chrome.storage.sync.set({ debugLoggingEnabled: enabled }, () => { + updateStatus(`Debug-Logging ${enabled ? 'aktiviert' : 'deaktiviert'} (wirksam ohne Reload)`, true); + }); + }); +} function reloadFacebookTabs() { chrome.tabs.query({ url: ['https://www.facebook.com/*', 'https://facebook.com/*'] }, (tabs) => {