diff --git a/apps/web-clipper-manifestv3/src/content/duplicate-dialog.ts b/apps/web-clipper-manifestv3/src/content/duplicate-dialog.ts new file mode 100644 index 000000000..4582f8ab9 --- /dev/null +++ b/apps/web-clipper-manifestv3/src/content/duplicate-dialog.ts @@ -0,0 +1,256 @@ +import { Logger } from '@/shared/utils'; +import { ThemeManager } from '@/shared/theme'; + +const logger = Logger.create('DuplicateDialog', 'content'); + +/** + * Duplicate Note Dialog + * Shows a modal dialog asking the user what to do when saving content from a URL that already has a note + */ +export class DuplicateDialog { + private dialog: HTMLElement | null = null; + private overlay: HTMLElement | null = null; + private resolvePromise: ((value: { action: 'append' | 'new' | 'cancel' }) => void) | null = null; + + /** + * Show the duplicate dialog and wait for user choice + */ + public async show(existingNoteId: string, url: string): Promise<{ action: 'append' | 'new' | 'cancel' }> { + logger.info('Showing duplicate dialog', { existingNoteId, url }); + + return new Promise((resolve) => { + this.resolvePromise = resolve; + this.createDialog(existingNoteId, url); + }); + } + + private async createDialog(existingNoteId: string, url: string): Promise { + // Detect current theme + const config = await ThemeManager.getThemeConfig(); + const effectiveTheme = ThemeManager.getEffectiveTheme(config); + const isDark = effectiveTheme === 'dark'; + + // Theme colors + const colors = { + overlay: isDark ? 'rgba(0, 0, 0, 0.75)' : 'rgba(0, 0, 0, 0.6)', + dialogBg: isDark ? '#2a2a2a' : '#ffffff', + textPrimary: isDark ? '#e8e8e8' : '#1a1a1a', + textSecondary: isDark ? '#a0a0a0' : '#666666', + border: isDark ? '#404040' : '#e0e0e0', + iconBg: isDark ? '#404040' : '#f0f0f0', + buttonPrimary: '#0066cc', + buttonPrimaryHover: '#0052a3', + buttonSecondaryBg: isDark ? '#3a3a3a' : '#ffffff', + buttonSecondaryBorder: isDark ? '#555555' : '#e0e0e0', + buttonSecondaryBorderHover: '#0066cc', + buttonSecondaryHoverBg: isDark ? '#454545' : '#f5f5f5', + }; + + // Create overlay - more opaque background + this.overlay = document.createElement('div'); + this.overlay.id = 'trilium-clipper-overlay'; + this.overlay.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: ${colors.overlay}; + z-index: 2147483646; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + `; + + // Create dialog - fully opaque (explicitly set opacity to prevent inheritance) + this.dialog = document.createElement('div'); + this.dialog.id = 'trilium-clipper-dialog'; + this.dialog.style.cssText = ` + background: ${colors.dialogBg}; + opacity: 1; + border-radius: 12px; + box-shadow: 0 20px 60px ${isDark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.3)'}; + padding: 24px; + max-width: 480px; + width: 90%; + z-index: 2147483647; + `; + + const hostname = new URL(url).hostname; + + this.dialog.innerHTML = ` +
+
+
+ ℹ️ +
+

+ Already Saved +

+
+

+ You've already saved content from ${hostname} to Trilium.

+ This new content will be added to your existing note. +

+
+ +
+ + + +
+ +
+ + View existing note → + + +
+ `; + + // Add hover effects via event listeners + const proceedBtn = this.dialog.querySelector('#trilium-dialog-proceed') as HTMLButtonElement; + const cancelBtn = this.dialog.querySelector('#trilium-dialog-cancel') as HTMLButtonElement; + const viewLink = this.dialog.querySelector('#trilium-dialog-view') as HTMLAnchorElement; + const dontAskCheckbox = this.dialog.querySelector('#trilium-dialog-dont-ask') as HTMLInputElement; + + proceedBtn.addEventListener('mouseenter', () => { + proceedBtn.style.background = colors.buttonPrimaryHover; + }); + proceedBtn.addEventListener('mouseleave', () => { + proceedBtn.style.background = colors.buttonPrimary; + }); + + cancelBtn.addEventListener('mouseenter', () => { + cancelBtn.style.background = colors.buttonSecondaryHoverBg; + cancelBtn.style.borderColor = colors.buttonSecondaryBorderHover; + }); + cancelBtn.addEventListener('mouseleave', () => { + cancelBtn.style.background = colors.buttonSecondaryBg; + cancelBtn.style.borderColor = colors.buttonSecondaryBorder; + }); + + // Add click handlers + proceedBtn.addEventListener('click', () => { + const dontAsk = dontAskCheckbox.checked; + this.handleChoice('append', dontAsk); + }); + + cancelBtn.addEventListener('click', () => this.handleChoice('cancel', false)); + + viewLink.addEventListener('click', (e) => { + e.preventDefault(); + this.handleViewNote(existingNoteId); + }); + + // Close on overlay click + this.overlay.addEventListener('click', (e) => { + if (e.target === this.overlay) { + this.handleChoice('cancel', false); + } + }); + + // Close on Escape key + const escapeHandler = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.handleChoice('cancel', false); + document.removeEventListener('keydown', escapeHandler); + } + }; + document.addEventListener('keydown', escapeHandler); + + // Append overlay and dialog separately to body (not nested!) + // This prevents the dialog from inheriting overlay's opacity + document.body.appendChild(this.overlay); + document.body.appendChild(this.dialog); + + // Position dialog on top of overlay + this.dialog.style.position = 'fixed'; + this.dialog.style.top = '50%'; + this.dialog.style.left = '50%'; + this.dialog.style.transform = 'translate(-50%, -50%)'; + + // Focus the proceed button by default + proceedBtn.focus(); + } + + private async handleChoice(action: 'append' | 'new' | 'cancel', dontAskAgain: boolean): Promise { + logger.info('User chose action', { action, dontAskAgain }); + + // Save "don't ask again" preference if checked + if (dontAskAgain && action === 'append') { + try { + await chrome.storage.sync.set({ 'auto_append_duplicates': true }); + logger.info('User preference saved: auto-append duplicates'); + } catch (error) { + logger.error('Failed to save user preference', error as Error); + } + } + + if (this.resolvePromise) { + this.resolvePromise({ action }); + this.resolvePromise = null; + } + + this.close(); + } + + private async handleViewNote(noteId: string): Promise { + logger.info('Opening note in Trilium', { noteId }); + + try { + // Send message to background to open the note + await chrome.runtime.sendMessage({ + type: 'OPEN_NOTE', + noteId + }); + } catch (error) { + logger.error('Failed to open note', error as Error); + } + } + + private close(): void { + // Remove overlay + if (this.overlay && this.overlay.parentNode) { + this.overlay.parentNode.removeChild(this.overlay); + } + + // Remove dialog (now separate from overlay) + if (this.dialog && this.dialog.parentNode) { + this.dialog.parentNode.removeChild(this.dialog); + } + + this.dialog = null; + this.overlay = null; + } +} diff --git a/apps/web-clipper-manifestv3/src/content/index.ts b/apps/web-clipper-manifestv3/src/content/index.ts new file mode 100644 index 000000000..8ae96130b --- /dev/null +++ b/apps/web-clipper-manifestv3/src/content/index.ts @@ -0,0 +1,1041 @@ +import { Logger, MessageUtils } from '@/shared/utils'; +import { ClipData, ImageData } from '@/shared/types'; +import { HTMLSanitizer } from '@/shared/html-sanitizer'; +import { DuplicateDialog } from './duplicate-dialog'; +import { Readability } from '@mozilla/readability'; + +const logger = Logger.create('Content', 'content'); + +/** + * Content script for the Trilium Web Clipper extension + * Handles page content extraction and user interactions + */ +class ContentScript { + private static instance: ContentScript | null = null; + private isInitialized = false; + private connectionState: 'disconnected' | 'connecting' | 'connected' = 'disconnected'; + private lastPingTime: number = 0; + + constructor() { + // Enhanced idempotency check + if (ContentScript.instance) { + logger.debug('Content script instance already exists, reusing...', { + isInitialized: ContentScript.instance.isInitialized, + connectionState: ContentScript.instance.connectionState + }); + + // If already initialized, we're good + if (ContentScript.instance.isInitialized) { + return ContentScript.instance; + } + + // If not initialized, continue initialization + logger.warn('Found uninitialized instance, completing initialization'); + } + + ContentScript.instance = this; + this.initialize(); + } + + private async initialize(): Promise { + if (this.isInitialized) { + logger.debug('Content script already initialized'); + return; + } + + try { + logger.info('Initializing content script...'); + + this.setConnectionState('connecting'); + + this.setupMessageHandler(); + + this.isInitialized = true; + this.setConnectionState('connected'); + logger.info('Content script initialized successfully'); + + // Announce readiness to background script + this.announceReady(); + } catch (error) { + this.setConnectionState('disconnected'); + logger.error('Failed to initialize content script', error as Error); + } + } + + private setConnectionState(state: 'disconnected' | 'connecting' | 'connected'): void { + this.connectionState = state; + logger.debug('Connection state changed', { state }); + } + + private announceReady(): void { + // Let the background script know we're ready + // This allows the background to track which tabs have loaded content scripts + chrome.runtime.sendMessage({ + type: 'CONTENT_SCRIPT_READY', + url: window.location.href, + timestamp: Date.now() + }).catch(() => { + // Background might not be listening yet, that's OK + // The declarative injection ensures we're available anyway + logger.debug('Could not announce ready to background (background may not be active)'); + }); + } private setupMessageHandler(): void { + // Remove any existing listeners first + if (chrome.runtime.onMessage.hasListeners()) { + chrome.runtime.onMessage.removeListener(this.handleMessage.bind(this)); + } + + chrome.runtime.onMessage.addListener( + MessageUtils.createResponseHandler(this.handleMessage.bind(this)) + ); + + logger.debug('Message handler setup complete'); + } + + private async handleMessage(message: any): Promise { + logger.debug('Received message', { type: message.type, message }); + + try { + switch (message.type) { + case 'PING': + // Simple health check - content script is ready if we can respond + this.lastPingTime = Date.now(); + return { + success: true, + timestamp: this.lastPingTime + }; + + case 'GET_SELECTION': + return this.getSelection(); + + case 'GET_PAGE_CONTENT': + return this.getPageContent(); + + case 'GET_SCREENSHOT_AREA': + return this.getScreenshotArea(); + + case 'SHOW_TOAST': + return this.showToast(message.message, message.variant, message.duration); + + case 'SHOW_DUPLICATE_DIALOG': + return this.showDuplicateDialog(message.existingNoteId, message.url); + + default: + logger.warn('Unknown message type', { message }); + return { success: false, error: 'Unknown message type' }; + } + } catch (error) { + logger.error('Error handling message', error as Error, { message }); + return { success: false, error: (error as Error).message }; + } + } + + private async showDuplicateDialog(existingNoteId: string, url: string): Promise<{ action: 'append' | 'new' | 'cancel' }> { + logger.info('Showing duplicate dialog', { existingNoteId, url }); + + const dialog = new DuplicateDialog(); + return await dialog.show(existingNoteId, url); + } + + private async getSelection(): Promise { + logger.debug('Getting selection...'); + + const selection = window.getSelection(); + if (!selection || selection.toString().trim() === '') { + throw new Error('No text selected'); + } + + const range = selection.getRangeAt(0); + const container = document.createElement('div'); + container.appendChild(range.cloneContents()); + + // Process embedded media in selection + this.processEmbeddedMedia(container); + + // Process images and make URLs absolute + const images = await this.processImages(container); + this.makeLinksAbsolute(container); + + return { + title: this.generateTitle('Selection'), + content: container.innerHTML, + url: window.location.href, + images, + type: 'selection' + }; + } + + private async getPageContent(): Promise { + logger.debug('Getting page content...'); + + try { + // ============================================================ + // 3-PHASE CLIENT-SIDE PROCESSING ARCHITECTURE + // ============================================================ + // Phase 1 (Content Script): Readability - Extract article from real DOM + // Phase 2 (Content Script): DOMPurify - Sanitize extracted HTML + // Phase 3 (Background Script): Cheerio - Final cleanup & processing + // ============================================================ + // This approach follows the MV2 extension pattern but adapted for MV3: + // - Phases 1 & 2 happen in content script (need real DOM) + // - Phase 3 happens in background script (no DOM needed) + // - Proper MV3 message passing between phases + // ============================================================ + + logger.info('Phase 1: Running Readability on real DOM...'); + + // Clone the document to preserve the original page + // Readability modifies the passed document, so we work with a copy + const documentCopy = document.cloneNode(true) as Document; + + // Capture pre-Readability stats + const preReadabilityStats = { + totalElements: document.body.querySelectorAll('*').length, + scripts: document.body.querySelectorAll('script').length, + styles: document.body.querySelectorAll('style, link[rel="stylesheet"]').length, + images: document.body.querySelectorAll('img').length, + links: document.body.querySelectorAll('a').length, + bodyLength: document.body.innerHTML.length + }; + + logger.debug('Pre-Readability DOM stats', preReadabilityStats); + + // Run @mozilla/readability to extract the main article content + const readability = new Readability(documentCopy); + const article = readability.parse(); + + if (!article) { + logger.warn('Readability failed to parse article, falling back to basic extraction'); + return this.getBasicPageContent(); + } + + // Create temp container to analyze extracted content + const tempContainer = document.createElement('div'); + tempContainer.innerHTML = article.content; + + const postReadabilityStats = { + totalElements: tempContainer.querySelectorAll('*').length, + paragraphs: tempContainer.querySelectorAll('p').length, + headings: tempContainer.querySelectorAll('h1, h2, h3, h4, h5, h6').length, + images: tempContainer.querySelectorAll('img').length, + links: tempContainer.querySelectorAll('a').length, + lists: tempContainer.querySelectorAll('ul, ol').length, + tables: tempContainer.querySelectorAll('table').length, + codeBlocks: tempContainer.querySelectorAll('pre, code').length, + blockquotes: tempContainer.querySelectorAll('blockquote').length, + contentLength: article.content?.length || 0 + }; + + logger.info('Phase 1 complete: Readability extracted article', { + title: article.title, + byline: article.byline, + excerpt: article.excerpt?.substring(0, 100), + textLength: article.textContent?.length || 0, + elementsRemoved: preReadabilityStats.totalElements - postReadabilityStats.totalElements, + contentStats: postReadabilityStats, + extraction: { + kept: postReadabilityStats.totalElements, + removed: preReadabilityStats.totalElements - postReadabilityStats.totalElements, + reductionPercent: Math.round(((preReadabilityStats.totalElements - postReadabilityStats.totalElements) / preReadabilityStats.totalElements) * 100) + } + }); + + // Create a temporary container for the article HTML + const articleContainer = document.createElement('div'); + articleContainer.innerHTML = article.content; + + // Process embedded media (videos, audio, advanced images) + this.processEmbeddedMedia(articleContainer); + + // Make all links absolute URLs + this.makeLinksAbsolute(articleContainer); + + // Process images and extract them for background downloading + const images = await this.processImages(articleContainer); + + logger.info('Phase 2: Sanitizing extracted HTML with DOMPurify...'); + + // Capture pre-sanitization stats + const preSanitizeStats = { + contentLength: articleContainer.innerHTML.length, + scripts: articleContainer.querySelectorAll('script, noscript').length, + eventHandlers: Array.from(articleContainer.querySelectorAll('*')).filter(el => + Array.from(el.attributes).some(attr => attr.name.startsWith('on')) + ).length, + iframes: articleContainer.querySelectorAll('iframe, frame, frameset').length, + objects: articleContainer.querySelectorAll('object, embed, applet').length, + forms: articleContainer.querySelectorAll('form, input, button, select, textarea').length, + base: articleContainer.querySelectorAll('base').length, + meta: articleContainer.querySelectorAll('meta').length + }; + + logger.debug('Pre-DOMPurify content analysis', preSanitizeStats); + + // Sanitize the extracted article HTML + const sanitizedHTML = HTMLSanitizer.sanitize(articleContainer.innerHTML, { + allowImages: true, + allowLinks: true, + allowDataUri: true + }); + + // Analyze sanitized content + const sanitizedContainer = document.createElement('div'); + sanitizedContainer.innerHTML = sanitizedHTML; + + const postSanitizeStats = { + contentLength: sanitizedHTML.length, + elements: sanitizedContainer.querySelectorAll('*').length, + scripts: sanitizedContainer.querySelectorAll('script, noscript').length, + eventHandlers: Array.from(sanitizedContainer.querySelectorAll('*')).filter(el => + Array.from(el.attributes).some(attr => attr.name.startsWith('on')) + ).length + }; + + const sanitizationResults = { + bytesRemoved: articleContainer.innerHTML.length - sanitizedHTML.length, + reductionPercent: Math.round(((articleContainer.innerHTML.length - sanitizedHTML.length) / articleContainer.innerHTML.length) * 100), + elementsStripped: { + scripts: preSanitizeStats.scripts - postSanitizeStats.scripts, + eventHandlers: preSanitizeStats.eventHandlers - postSanitizeStats.eventHandlers, + iframes: preSanitizeStats.iframes, + forms: preSanitizeStats.forms, + objects: preSanitizeStats.objects, + base: preSanitizeStats.base, + meta: preSanitizeStats.meta + } + }; + + logger.info('Phase 2 complete: DOMPurify sanitized HTML', { + originalLength: articleContainer.innerHTML.length, + sanitizedLength: sanitizedHTML.length, + ...sanitizationResults, + securityThreatsRemoved: Object.values(sanitizationResults.elementsStripped).reduce((a, b) => a + b, 0) + }); + + // Extract metadata (dates) from the page + const dates = this.extractDocumentDates(); + const labels: Record = {}; + + if (dates.publishedDate) { + labels['publishedDate'] = dates.publishedDate.toISOString().substring(0, 10); + } + if (dates.modifiedDate) { + labels['modifiedDate'] = dates.modifiedDate.toISOString().substring(0, 10); + } + + logger.info('Content extraction complete - ready for Phase 3 in background script', { + title: article.title, + contentLength: sanitizedHTML.length, + imageCount: images.length, + url: window.location.href + }); + + // Return the sanitized article content + // Background script will handle Phase 3 (Cheerio processing) + return { + title: article.title || this.getPageTitle(), + content: sanitizedHTML, + url: window.location.href, + images: images, + type: 'page', + metadata: { + publishedDate: dates.publishedDate?.toISOString(), + modifiedDate: dates.modifiedDate?.toISOString(), + labels, + readabilityProcessed: true, // Flag to indicate Readability was successful + excerpt: article.excerpt + } + }; + } catch (error) { + logger.error('Failed to capture page content with Readability', error as Error); + // Fallback to basic content extraction + return this.getBasicPageContent(); + } + } + + private async getBasicPageContent(): Promise { + const article = this.findMainContent(); + + // Process embedded media (videos, audio, advanced images) + this.processEmbeddedMedia(article); + + const images = await this.processImages(article); + this.makeLinksAbsolute(article); + + return { + title: this.getPageTitle(), + content: article.innerHTML, + url: window.location.href, + images, + type: 'page', + metadata: { + publishedDate: this.extractPublishedDate(), + modifiedDate: this.extractModifiedDate() + } + }; + } + + private findMainContent(): HTMLElement { + // Try common content selectors + const selectors = [ + 'article', + 'main', + '[role="main"]', + '.content', + '.post-content', + '.entry-content', + '#content', + '#main-content', + '.main-content' + ]; + + for (const selector of selectors) { + const element = document.querySelector(selector) as HTMLElement; + if (element && element.innerText.trim().length > 100) { + return element.cloneNode(true) as HTMLElement; + } + } + + // Fallback: try to find the element with most text content + const candidates = Array.from(document.querySelectorAll('div, section, article')); + let bestElement = document.body; + let maxTextLength = 0; + + candidates.forEach(element => { + const htmlElement = element as HTMLElement; + const textLength = htmlElement.innerText?.trim().length || 0; + if (textLength > maxTextLength) { + maxTextLength = textLength; + bestElement = htmlElement; + } + }); + + return bestElement.cloneNode(true) as HTMLElement; + } + + /** + * Process images by replacing src with placeholder IDs + * This allows the background script to download images without CORS restrictions + * Similar to MV2 extension approach + */ + private processImages(container: HTMLElement): ImageData[] { + const imgElements = Array.from(container.querySelectorAll('img')); + const images: ImageData[] = []; + + for (const img of imgElements) { + if (!img.src) continue; + + // Make URL absolute first + const absoluteUrl = this.makeAbsoluteUrl(img.src); + + // Check if we already have this image (avoid duplicates) + const existingImage = images.find(image => image.src === absoluteUrl); + + if (existingImage) { + // Reuse existing placeholder ID for duplicate images + img.src = existingImage.imageId; + logger.debug('Reusing placeholder for duplicate image', { + src: absoluteUrl, + placeholder: existingImage.imageId + }); + } else { + // Generate a random placeholder ID + const imageId = this.generateRandomId(20); + + images.push({ + imageId: imageId, // Must be 'imageId' to match MV2 format + src: absoluteUrl + }); + + // Replace src with placeholder - background script will download later + img.src = imageId; + + logger.debug('Created placeholder for image', { + originalSrc: absoluteUrl, + placeholder: imageId + }); + } + + // Also handle srcset for responsive images + if (img.srcset) { + const srcsetParts = img.srcset.split(',').map(part => { + const [url, descriptor] = part.trim().split(/\s+/); + return `${this.makeAbsoluteUrl(url)}${descriptor ? ' ' + descriptor : ''}`; + }); + img.srcset = srcsetParts.join(', '); + } + } + + logger.info('Processed images with placeholders', { + totalImages: images.length, + uniqueImages: images.length + }); + + return images; + } + + /** + * Generate a random ID for image placeholders + */ + private generateRandomId(length: number): string { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; + } + + private makeLinksAbsolute(container: HTMLElement): void { + const links = container.querySelectorAll('a[href]'); + + links.forEach(link => { + const href = link.getAttribute('href'); + if (href) { + link.setAttribute('href', this.makeAbsoluteUrl(href)); + } + }); + } + + private makeAbsoluteUrl(url: string): string { + try { + return new URL(url, window.location.href).href; + } catch { + return url; + } + } + + private getPageTitle(): string { + // Try multiple sources for the title + const sources = [ + () => document.querySelector('meta[property="og:title"]')?.getAttribute('content'), + () => document.querySelector('meta[name="twitter:title"]')?.getAttribute('content'), + () => document.querySelector('h1')?.textContent?.trim(), + () => document.title.trim(), + () => 'Untitled Page' + ]; + + for (const source of sources) { + const title = source(); + if (title && title.length > 0) { + return title; + } + } + + return 'Untitled Page'; + } + + private generateTitle(prefix: string): string { + const pageTitle = this.getPageTitle(); + return `${prefix} from ${pageTitle}`; + } + + private extractPublishedDate(): string | undefined { + const selectors = [ + 'meta[property="article:published_time"]', + 'meta[name="publishdate"]', + 'meta[name="date"]', + 'time[pubdate]', + 'time[datetime]' + ]; + + for (const selector of selectors) { + const element = document.querySelector(selector); + const content = element?.getAttribute('content') || + element?.getAttribute('datetime') || + element?.textContent?.trim(); + + if (content) { + try { + return new Date(content).toISOString(); + } catch { + continue; + } + } + } + + return undefined; + } + + private extractModifiedDate(): string | undefined { + const selectors = [ + 'meta[property="article:modified_time"]', + 'meta[name="last-modified"]' + ]; + + for (const selector of selectors) { + const element = document.querySelector(selector); + const content = element?.getAttribute('content'); + + if (content) { + try { + return new Date(content).toISOString(); + } catch { + continue; + } + } + } + + return undefined; + } + + + + private extractDocumentDates(): { publishedDate?: Date; modifiedDate?: Date } { + const dates: { publishedDate?: Date; modifiedDate?: Date } = {}; + + // Try to extract published date + const publishedMeta = document.querySelector("meta[property='article:published_time']"); + if (publishedMeta) { + const publishedContent = publishedMeta.getAttribute('content'); + if (publishedContent) { + try { + dates.publishedDate = new Date(publishedContent); + } catch (error) { + logger.warn('Failed to parse published date', { publishedContent }); + } + } + } + + // Try to extract modified date + const modifiedMeta = document.querySelector("meta[property='article:modified_time']"); + if (modifiedMeta) { + const modifiedContent = modifiedMeta.getAttribute('content'); + if (modifiedContent) { + try { + dates.modifiedDate = new Date(modifiedContent); + } catch (error) { + logger.warn('Failed to parse modified date', { modifiedContent }); + } + } + } + + // TODO: Add support for JSON-LD structured data extraction + // This could include more sophisticated date extraction from schema.org markup + + return dates; + } + + /** + * Enhanced content processing for embedded media + * Handles videos, audio, images, and other embedded content + */ + private processEmbeddedMedia(container: HTMLElement): void { + // Process video embeds (YouTube, Vimeo, etc.) + this.processVideoEmbeds(container); + + // Process audio embeds (Spotify, SoundCloud, etc.) + this.processAudioEmbeds(container); + + // Process advanced image content (carousels, galleries, etc.) + this.processAdvancedImages(container); + + // Process social media embeds + this.processSocialEmbeds(container); + } + + private processVideoEmbeds(container: HTMLElement): void { + // YouTube embeds + const youtubeEmbeds = container.querySelectorAll('iframe[src*="youtube.com"], iframe[src*="youtu.be"]'); + youtubeEmbeds.forEach((embed) => { + const iframe = embed as HTMLIFrameElement; + + // Extract video ID and create watch URL + const videoId = this.extractYouTubeId(iframe.src); + const watchUrl = videoId ? `https://www.youtube.com/watch?v=${videoId}` : iframe.src; + + const wrapper = document.createElement('div'); + wrapper.className = 'trilium-video-link youtube'; + wrapper.innerHTML = `

🎥 Watch on YouTube

`; + + iframe.parentNode?.replaceChild(wrapper, iframe); + logger.debug('Processed YouTube embed', { src: iframe.src, watchUrl }); + }); + + // Vimeo embeds + const vimeoEmbeds = container.querySelectorAll('iframe[src*="vimeo.com"]'); + vimeoEmbeds.forEach((embed) => { + const iframe = embed as HTMLIFrameElement; + const wrapper = document.createElement('div'); + wrapper.className = 'trilium-video-link vimeo'; + wrapper.innerHTML = `

🎥 Watch on Vimeo

`; + iframe.parentNode?.replaceChild(wrapper, iframe); + logger.debug('Processed Vimeo embed', { src: iframe.src }); + }); + + // Native HTML5 videos + const videoElements = container.querySelectorAll('video'); + videoElements.forEach((video) => { + const wrapper = document.createElement('div'); + wrapper.className = 'trilium-video-native'; + + const sources = Array.from(video.querySelectorAll('source')).map(s => s.src).join(', '); + const videoSrc = video.src || sources; + + wrapper.innerHTML = `

🎬 Video File

`; + video.parentNode?.replaceChild(wrapper, video); + logger.debug('Processed native video', { src: videoSrc }); + }); + } + + private processAudioEmbeds(container: HTMLElement): void { + // Spotify embeds + const spotifyEmbeds = container.querySelectorAll('iframe[src*="spotify.com"]'); + spotifyEmbeds.forEach((embed) => { + const iframe = embed as HTMLIFrameElement; + const wrapper = document.createElement('div'); + wrapper.className = 'trilium-audio-embed spotify-embed'; + wrapper.innerHTML = ` +

Spotify: ${iframe.src}

+
[Spotify Audio Embedded]
+ `; + iframe.parentNode?.replaceChild(wrapper, iframe); + logger.debug('Processed Spotify embed', { src: iframe.src }); + }); + + // SoundCloud embeds + const soundcloudEmbeds = container.querySelectorAll('iframe[src*="soundcloud.com"]'); + soundcloudEmbeds.forEach((embed) => { + const iframe = embed as HTMLIFrameElement; + const wrapper = document.createElement('div'); + wrapper.className = 'trilium-audio-embed soundcloud-embed'; + wrapper.innerHTML = ` +

SoundCloud: ${iframe.src}

+
[SoundCloud Audio Embedded]
+ `; + iframe.parentNode?.replaceChild(wrapper, iframe); + logger.debug('Processed SoundCloud embed', { src: iframe.src }); + }); + + // Native HTML5 audio + const audioElements = container.querySelectorAll('audio'); + audioElements.forEach((audio) => { + const wrapper = document.createElement('div'); + wrapper.className = 'trilium-audio-native'; + + const sources = Array.from(audio.querySelectorAll('source')).map(s => s.src).join(', '); + const audioSrc = audio.src || sources; + + wrapper.innerHTML = ` +

Audio: ${audioSrc}

+
[Audio Content]
+ `; + audio.parentNode?.replaceChild(wrapper, audio); + logger.debug('Processed native audio', { src: audioSrc }); + }); + } + + private processAdvancedImages(container: HTMLElement): void { + // Handle image galleries and carousels + const galleries = container.querySelectorAll('.gallery, .carousel, .slider, [class*="gallery"], [class*="carousel"], [class*="slider"]'); + galleries.forEach((gallery) => { + const images = gallery.querySelectorAll('img'); + if (images.length > 1) { + const wrapper = document.createElement('div'); + wrapper.className = 'trilium-image-gallery'; + wrapper.innerHTML = `

Image Gallery (${images.length} images):

`; + + images.forEach((img, index) => { + const imgWrapper = document.createElement('div'); + imgWrapper.className = 'gallery-image'; + imgWrapper.innerHTML = `

Image ${index + 1}: ${img.alt || ''}

`; + wrapper.appendChild(imgWrapper); + }); + + gallery.parentNode?.replaceChild(wrapper, gallery); + logger.debug('Processed image gallery', { imageCount: images.length }); + } + }); + + // Handle lazy-loaded images with data-src + const lazyImages = container.querySelectorAll('img[data-src], img[data-lazy-src]'); + lazyImages.forEach((img) => { + const imgElement = img as HTMLImageElement; + const dataSrc = imgElement.dataset.src || imgElement.dataset.lazySrc; + if (dataSrc && !imgElement.src) { + imgElement.src = dataSrc; + logger.debug('Processed lazy-loaded image', { dataSrc }); + } + }); + } + + private processSocialEmbeds(container: HTMLElement): void { + // Twitter embeds + const twitterEmbeds = container.querySelectorAll('blockquote.twitter-tweet, iframe[src*="twitter.com"]'); + twitterEmbeds.forEach((embed) => { + const wrapper = document.createElement('div'); + wrapper.className = 'trilium-social-embed twitter-embed'; + + // Try to extract tweet URL from various attributes + const links = embed.querySelectorAll('a[href*="twitter.com"], a[href*="x.com"]'); + const tweetUrl = links.length > 0 ? (links[links.length - 1] as HTMLAnchorElement).href : ''; + + wrapper.innerHTML = ` +

Twitter/X Post: ${tweetUrl ? `${tweetUrl}` : '[Twitter Embed]'}

+
+ ${embed.textContent || '[Twitter content]'} +
+ `; + embed.parentNode?.replaceChild(wrapper, embed); + logger.debug('Processed Twitter embed', { url: tweetUrl }); + }); + + // Instagram embeds + const instagramEmbeds = container.querySelectorAll('blockquote[data-instgrm-captioned], iframe[src*="instagram.com"]'); + instagramEmbeds.forEach((embed) => { + const wrapper = document.createElement('div'); + wrapper.className = 'trilium-social-embed instagram-embed'; + wrapper.innerHTML = ` +

Instagram Post: [Instagram Embed]

+
+ ${embed.textContent || '[Instagram content]'} +
+ `; + embed.parentNode?.replaceChild(wrapper, embed); + logger.debug('Processed Instagram embed'); + }); + } + + /** + * Extract YouTube video ID from various URL formats + */ + private extractYouTubeId(url: string): string | null { + const patterns = [ + /youtube\.com\/embed\/([^?&]+)/, + /youtube\.com\/watch\?v=([^&]+)/, + /youtu\.be\/([^?&]+)/, + /youtube\.com\/v\/([^?&]+)/ + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match && match[1]) return match[1]; + } + + return null; + } + + /** + * Screenshot area selection functionality + * Allows user to drag and select a rectangular area for screenshot capture + */ + private async getScreenshotArea(): Promise<{ x: number; y: number; width: number; height: number }> { + return new Promise((resolve, reject) => { + try { + // Create overlay elements + const overlay = this.createScreenshotOverlay(); + const messageBox = this.createScreenshotMessage(); + const selection = this.createScreenshotSelection(); + + document.body.appendChild(overlay); + document.body.appendChild(messageBox); + document.body.appendChild(selection); + + // Focus the message box for keyboard events + messageBox.focus(); + + let isDragging = false; + let startX = 0; + let startY = 0; + + const cleanup = () => { + document.body.removeChild(overlay); + document.body.removeChild(messageBox); + document.body.removeChild(selection); + }; + + const handleMouseDown = (e: MouseEvent) => { + isDragging = true; + startX = e.clientX; + startY = e.clientY; + selection.style.left = startX + 'px'; + selection.style.top = startY + 'px'; + selection.style.width = '0px'; + selection.style.height = '0px'; + selection.style.display = 'block'; + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!isDragging) return; + + const currentX = e.clientX; + const currentY = e.clientY; + const width = Math.abs(currentX - startX); + const height = Math.abs(currentY - startY); + const left = Math.min(currentX, startX); + const top = Math.min(currentY, startY); + + selection.style.left = left + 'px'; + selection.style.top = top + 'px'; + selection.style.width = width + 'px'; + selection.style.height = height + 'px'; + }; + + const handleMouseUp = (e: MouseEvent) => { + if (!isDragging) return; + isDragging = false; + + const currentX = e.clientX; + const currentY = e.clientY; + const width = Math.abs(currentX - startX); + const height = Math.abs(currentY - startY); + const left = Math.min(currentX, startX); + const top = Math.min(currentY, startY); + + cleanup(); + + // Return the selected area coordinates + resolve({ x: left, y: top, width, height }); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + cleanup(); + reject(new Error('Screenshot selection cancelled')); + } + }; + + // Add event listeners + overlay.addEventListener('mousedown', handleMouseDown); + overlay.addEventListener('mousemove', handleMouseMove); + overlay.addEventListener('mouseup', handleMouseUp); + messageBox.addEventListener('keydown', handleKeyDown); + + logger.info('Screenshot area selection mode activated'); + } catch (error) { + logger.error('Failed to initialize screenshot area selection', error as Error); + reject(error); + } + }); + } + + private createScreenshotOverlay(): HTMLDivElement { + const overlay = document.createElement('div'); + Object.assign(overlay.style, { + position: 'fixed', + top: '0', + left: '0', + width: '100%', + height: '100%', + backgroundColor: 'black', + opacity: '0.6', + zIndex: '99999998', + cursor: 'crosshair' + }); + return overlay; + } + + private createScreenshotMessage(): HTMLDivElement { + const messageBox = document.createElement('div'); + messageBox.tabIndex = 0; // Make it focusable + messageBox.textContent = 'Drag and release to capture a screenshot (Press ESC to cancel)'; + + Object.assign(messageBox.style, { + position: 'fixed', + top: '10px', + left: '50%', + transform: 'translateX(-50%)', + width: '400px', + padding: '15px', + backgroundColor: 'white', + color: 'black', + border: '2px solid #333', + borderRadius: '8px', + fontSize: '14px', + textAlign: 'center', + zIndex: '99999999', + fontFamily: 'system-ui, -apple-system, sans-serif', + boxShadow: '0 4px 12px rgba(0,0,0,0.3)' + }); + + return messageBox; + } + + private createScreenshotSelection(): HTMLDivElement { + const selection = document.createElement('div'); + Object.assign(selection.style, { + position: 'fixed', + border: '2px solid #ff0000', + backgroundColor: 'rgba(255,0,0,0.1)', + zIndex: '99999997', + pointerEvents: 'none', + display: 'none' + }); + return selection; + } + + private showToast(message: string, variant: string = 'info', duration: number = 3000): { success: boolean } { + // Create a simple toast notification + const toast = document.createElement('div'); + toast.className = `trilium-toast trilium-toast--${variant}`; + toast.textContent = message; + + // Basic styling + Object.assign(toast.style, { + position: 'fixed', + top: '20px', + right: '20px', + padding: '12px 16px', + borderRadius: '4px', + color: 'white', + fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + fontSize: '14px', + zIndex: '10000', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + backgroundColor: this.getToastColor(variant), + opacity: '0', + transform: 'translateX(100%)', + transition: 'all 0.3s ease' + }); + + document.body.appendChild(toast); + + // Animate in + requestAnimationFrame(() => { + toast.style.opacity = '1'; + toast.style.transform = 'translateX(0)'; + }); + + // Auto remove + setTimeout(() => { + toast.style.opacity = '0'; + toast.style.transform = 'translateX(100%)'; + setTimeout(() => { + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }, 300); + }, duration); + + return { success: true }; + } + + private getToastColor(variant: string): string { + const colors = { + success: '#22c55e', + error: '#ef4444', + warning: '#f59e0b', + info: '#3b82f6' + }; + + return colors[variant as keyof typeof colors] || colors.info; + } +} + +// Initialize the content script +try { + logger.info('Content script file loaded, creating instance...'); + new ContentScript(); +} catch (error) { + logger.error('Failed to create ContentScript instance', error as Error); + + // Try to send error to background script + try { + chrome.runtime.sendMessage({ + type: 'CONTENT_SCRIPT_ERROR', + error: (error as Error).message + }); + } catch (e) { + console.error('Content script failed to initialize:', error); + } +}