From f37853a12be2f6bbfa907cce5c8fae761ba50399 Mon Sep 17 00:00:00 2001 From: Yandrik Date: Tue, 24 Jun 2025 17:10:44 +0200 Subject: [PATCH] feat: v13 with auto-open sidebar --- chatwoot_quote_generator-0.13.user.js | 786 ++++++++++++++++++++++++++ 1 file changed, 786 insertions(+) create mode 100644 chatwoot_quote_generator-0.13.user.js diff --git a/chatwoot_quote_generator-0.13.user.js b/chatwoot_quote_generator-0.13.user.js new file mode 100644 index 0000000..45eeee7 --- /dev/null +++ b/chatwoot_quote_generator-0.13.user.js @@ -0,0 +1,786 @@ +// ==UserScript== +// @name Chatwoot Quote Generator +// @namespace http://tampermonkey.net/ +// @version 0.13 +// @description Generate a formatted quote from Chatwoot conversation +// @author You +// @match https://chatwoot.abawo.de/app/* +// @grant GM_setValue +// @grant GM_getValue +// ==/UserScript== + +(function() { + 'use strict'; + + // Configuration with default values + let config = { + maxMessages: 5, + language: 'en', + showExclusionIndicator: true, + autoOpenParticipants: true + }; + + // Separator to identify the end of quotes + const QUOTE_SEPARATOR = "----------------- "; + + // Month translations for German date formatting + const monthTranslations = { + 'Jan': 'Jan', + 'Feb': 'Feb', + 'Mar': 'Mär', + 'Apr': 'Apr', + 'May': 'Mai', + 'Jun': 'Jun', + 'Jul': 'Jul', + 'Aug': 'Aug', + 'Sep': 'Sep', + 'Oct': 'Okt', + 'Nov': 'Nov', + 'Dec': 'Dez' + }; + + // Translations + const translations = { + en: { + wrote_at: 'wrote at', + from_abawo: 'from abawo', + max_messages: 'Maximum messages to quote:', + language: 'Language:', + save: 'Save', + cancel: 'Cancel', + quote_config: 'Quote Configuration', + english: 'English', + german: 'German', + show_exclusion: 'Show "..." for excluded messages:', + excluded_messages: '... (older messages hidden)', + auto_open: 'Auto-open participants section:' + }, + de: { + wrote_at: 'schrieb am', + from_abawo: 'von abawo', + max_messages: 'Maximale Anzahl zu zitierender Nachrichten:', + language: 'Sprache:', + save: 'Speichern', + cancel: 'Abbrechen', + quote_config: 'Zitat-Konfiguration', + english: 'Englisch', + german: 'Deutsch', + show_exclusion: 'Zeige "..." für ausgeschlossene Nachrichten:', + excluded_messages: '... (ältere Nachrichten versteckt)', + auto_open: 'Teilnehmerbereich automatisch öffnen:' + } + }; + + // Helper function to get translation + function t(key) { + return translations[config.language][key] || translations.en[key]; + } + + // Function to format date in German style + function formatDateGerman(dateStr) { + // Common format is "Jun 17, 2:33 PM" + try { + // Extract components from the date string + const parts = dateStr.match(/(\w+)\s+(\d+),\s+(\d+):(\d+)\s+(\w+)/); + if (!parts) return dateStr; // Return original if no match + + const month = monthTranslations[parts[1]] || parts[1]; + const day = parts[2]; + let hour = parseInt(parts[3]); + const minute = parts[4]; + const ampm = parts[5]; + + // Convert to 24-hour format + if (ampm === 'PM' && hour < 12) hour += 12; + if (ampm === 'AM' && hour === 12) hour = 0; + + // Format as "17. Jun um 14:33" + return `${day}. ${month} um ${hour.toString().padStart(2, '0')}:${minute}`; + } catch (e) { + console.error('Error formatting date:', e); + return dateStr; // Return original on error + } + } + + // Load saved configuration if available + try { + const savedConfig = GM_getValue('chatwootQuoteConfig'); + if (savedConfig) { + config = JSON.parse(savedConfig); + } + } catch (e) { + console.error('Failed to load configuration:', e); + } + + // Save configuration + function saveConfig() { + GM_setValue('chatwootQuoteConfig', JSON.stringify(config)); + } + + // Function to wait for an element to be available in the DOM + function waitForElement(selector, callback, maxWaitTime = 10000) { + const startTime = Date.now(); + + const checkExist = setInterval(function() { + const element = document.querySelector(selector); + + if (element) { + clearInterval(checkExist); + callback(element); + } else if (Date.now() - startTime > maxWaitTime) { + clearInterval(checkExist); + console.log(`Element ${selector} not found after ${maxWaitTime}ms`); + } + }, 100); + } + + // IMPROVED: Function to extract message text excluding blockquotes and HR elements + function extractTextExcludingQuotes(textContentElement) { + if (!textContentElement) return ''; + + // Check if there are blockquotes + const blockquotes = textContentElement.querySelectorAll('blockquote'); + const hrElements = textContentElement.querySelectorAll('hr'); + + // If there are no blockquotes or hr elements, return the full text + if (blockquotes.length === 0 && hrElements.length === 0) { + // Customer email messages often have a different structure + if (textContentElement.querySelector('div[dir="auto"]')) { + return textContentElement.querySelector('div[dir="auto"]').textContent.trim(); + } + // Messages with paragraphs + else if (textContentElement.querySelectorAll('p').length > 0) { + const paragraphs = textContentElement.querySelectorAll('p'); + return Array.from(paragraphs) + .map(p => p.textContent.trim()) + .filter(t => t) + .join('\n'); + } + // Fallback for any other format + else { + return textContentElement.textContent.trim(); + } + } + + // Clone the node to avoid modifying the original + const clonedNode = textContentElement.cloneNode(true); + + // Remove all blockquotes + blockquotes.forEach((blockquote, index) => { + const correspondingBlockquote = clonedNode.querySelectorAll('blockquote')[index]; + if (correspondingBlockquote) { + correspondingBlockquote.remove(); + } + }); + + // Remove all hr elements + hrElements.forEach((hr, index) => { + const correspondingHr = clonedNode.querySelectorAll('hr')[index]; + if (correspondingHr) { + correspondingHr.remove(); + } + }); + + // IMPROVED: Extract text with better line break handling + let text = ''; + + // Process paragraph elements if they exist + if (clonedNode.querySelectorAll('p').length > 0) { + const paragraphs = clonedNode.querySelectorAll('p'); + text = Array.from(paragraphs) + .map(p => p.textContent.trim()) + .filter(t => t) + .join('\n'); + } else { + // Get the text content + text = clonedNode.textContent.trim(); + } + + // IMPROVED: Clean up problematic formatting + // Replace double >> with single > + text = text.replace(/>>\s*>/g, '> '); + + // Fix missing spaces after commas in quoted content + text = text.replace(/,(?=\S)/g, ', '); + + // Ensure proper line breaks for quoted content + // Look for patterns like "> text> more text" and fix them + text = text.replace(/>\s*([^>]+?)>\s*/g, "> $1\n> "); + + // Fix cases where quote separator is in the middle of text + if (text.includes(QUOTE_SEPARATOR)) { + const parts = text.split(QUOTE_SEPARATOR); + text = parts[0].trim(); + } + + // Normalize multiple consecutive line breaks + text = text.replace(/\n{3,}/g, '\n\n'); + + return text; + } + + // Helper functions for auto-opening UI elements + function isSidebarOpen() { + // Check if sidebar is open by looking for the caret direction + const sidebarToggle = document.querySelector('button[data-v-2defdc7c=""] .i-ph-caret-right-fill'); + return !!sidebarToggle; // If caret-right-fill exists, sidebar is open + } + + function isParticipantsSectionExpanded() { + // Check if participants section is expanded by looking for the minus icon + const participantsSection = document.querySelector('[data-v-70238fc0=""] button'); + if (!participantsSection) return false; + + // Look for minus icon (expanded state) + const minusIcon = participantsSection.querySelector('svg path[d*="M3.997 13H20"]'); + return !!minusIcon; + } + + function hasParticipantsLoaded() { + // Check if participants have loaded (not showing "No one is participating") + const participantsText = document.querySelector('[data-v-d343fa58=""] .total-watchers'); + if (!participantsText) return false; + + const text = participantsText.textContent.trim(); + return !text.includes('No one is participating') && text.includes('participating'); + } + + function clickSidebarToggle() { + const sidebarToggle = document.querySelector('button[data-v-2defdc7c=""]'); + if (sidebarToggle) { + sidebarToggle.click(); + return true; + } + return false; + } + + function clickParticipantsToggle() { + const participantsToggle = document.querySelector('[data-v-70238fc0=""] button'); + if (participantsToggle) { + participantsToggle.click(); + return true; + } + return false; + } + + // Function to wait with timeout + function waitFor(conditionFn, timeout = 1000) { + return new Promise((resolve) => { + const startTime = Date.now(); + const checkCondition = () => { + if (conditionFn()) { + resolve(true); + } else if (Date.now() - startTime > timeout) { + resolve(false); + } else { + setTimeout(checkCondition, 50); + } + }; + checkCondition(); + }); + } + + // Function to attempt auto-opening sidebar and participants section + async function ensureParticipantsSectionAccessible() { + if (!config.autoOpenParticipants) { + return false; + } + + let opened = false; + + // Step 1: Open sidebar if closed + if (!isSidebarOpen()) { + console.log('Opening sidebar...'); + if (clickSidebarToggle()) { + opened = true; + await waitFor(isSidebarOpen, 200); + } + } + + // Step 2: Expand participants section if collapsed + if (!isParticipantsSectionExpanded()) { + console.log('Expanding participants section...'); + if (clickParticipantsToggle()) { + opened = true; + await waitFor(isParticipantsSectionExpanded, 200); + } + } + + // Step 3: Wait for participants to load + if (opened) { + console.log('Waiting for participants to load...'); + await waitFor(hasParticipantsLoaded, 600); + } + + return opened; + } + + // Function to build lookup table from conversation participants + function buildAgentLookupTable() { + const lookupTable = {}; + + // Try to find the conversation participants section + const participantsSection = document.querySelector('[data-v-d343fa58=""][inbox-id]'); + if (!participantsSection) { + return lookupTable; + } + + // Look for participant entries in overlapping thumbnails section + const thumbnails = participantsSection.querySelectorAll('.overlapping-thumbnails .user-thumbnail-box'); + thumbnails.forEach(thumbnail => { + const nameAttr = thumbnail.getAttribute('data-original-title'); + const avatarContainer = thumbnail.querySelector('.avatar-container'); + + if (nameAttr && avatarContainer) { + const initials = avatarContainer.textContent.trim(); + if (initials && nameAttr) { + lookupTable[initials] = nameAttr; + } + } + }); + + // Also look in the dropdown participant list if available + const dropdownItems = participantsSection.querySelectorAll('.dropdown-menu__item'); + dropdownItems.forEach(item => { + const nameSpan = item.querySelector('span[title]'); + const avatarContainer = item.querySelector('.avatar-container'); + + if (nameSpan && avatarContainer) { + const fullName = nameSpan.getAttribute('title') || nameSpan.textContent.trim(); + const initials = avatarContainer.textContent.trim(); + if (initials && fullName) { + lookupTable[initials] = fullName; + } + } + }); + + return lookupTable; + } + + // Function to extract agent initials from avatar and get full name if available + function extractAgentInitials(messageElement, agentLookup = {}) { + // Look for avatar container within the message + const avatarContainer = messageElement.querySelector('.sender--info .avatar-container'); + let initials = null; + + if (avatarContainer) { + initials = avatarContainer.textContent.trim(); + } else { + // Fallback: look for other possible avatar selectors + const altAvatarContainer = messageElement.querySelector('.user-thumbnail-box .avatar-container'); + if (altAvatarContainer) { + initials = altAvatarContainer.textContent.trim(); + } + } + + if (initials && initials.length >= 1) { + // Try to get full name from lookup table + const fullName = agentLookup[initials]; + if (fullName) { + return fullName; + } + return initials; + } + + // If no initials found, return generic fallback + return "Agent"; + } + + // Function to extract message data + async function extractMessages() { + // Build agent lookup table from conversation participants + let agentLookup = buildAgentLookupTable(); + + // If we don't have enough data and auto-open is enabled, try to access participants section + if (Object.keys(agentLookup).length === 0 && config.autoOpenParticipants) { + const opened = await ensureParticipantsSectionAccessible(); + if (opened) { + // Try building lookup table again after opening UI elements + agentLookup = buildAgentLookupTable(); + } + } + + // Get customer name + const customerNameElement = document.querySelector('.conversation-details-wrap .text-base.font-medium.truncate.leading-tight.text-n-slate-12'); + const customerName = customerNameElement ? customerNameElement.textContent.trim() : "Customer"; + + // Get all message items + const messages = []; + const messageItems = document.querySelectorAll('.conversation-panel > li'); + + messageItems.forEach(li => { + // Skip system messages (center) + if (li.classList.contains('center')) return; + + const bubble = li.querySelector('.bubble'); + if (!bubble) return; + + // Skip private notes + if (bubble.classList.contains('is-private')) return; + + const isCustomer = li.classList.contains('left'); + + // Determine sender name + let senderName; + if (isCustomer) { + senderName = customerName; + } else { + // Extract agent name/initials from the avatar within this specific message + const agentName = extractAgentInitials(li, agentLookup); + senderName = `${agentName} ${t('from_abawo')}`; + } + + // Extract timestamp + const timeElement = li.querySelector('.time'); + const time = timeElement ? timeElement.textContent.trim() : ""; + + // Format the time according to the selected language + const formattedTime = config.language === 'de' ? formatDateGerman(time) : time; + + // Extract message text + const textWrap = bubble.querySelector('.message-text__wrap'); + if (!textWrap) return; + + const textContentElement = textWrap.querySelector('.text-content'); + + // Extract text while excluding blockquotes + let text = extractTextExcludingQuotes(textContentElement); + + if (text) { + messages.push({ + isCustomer, + name: senderName, + time: formattedTime, + text + }); + } + }); + + // Keep track of whether messages were excluded + const messagesExcluded = messages.length > config.maxMessages; + + // Limit the number of messages based on configuration + if (messagesExcluded) { + messages.splice(0, messages.length - config.maxMessages); + } + + return { messages, messagesExcluded }; + } + + // Function to format messages as a quote + function formatQuote(data) { + let quote = ""; + + // Add indicator for excluded messages if enabled and messages were excluded + if (config.showExclusionIndicator && data.messagesExcluded) { + quote += `> ${t('excluded_messages')}\n>\n`; + } + + data.messages.forEach((message, index) => { + if (index > 0) { + quote += "\n>\n"; // Empty line with ">" between messages + } + + quote += `> ${message.name} ${t('wrote_at')} ${message.time}:\n> ${message.text.replace(/\n/g, '\n> ')}`; + }); + + // Add separator to mark the end of quotes + if (quote) { + quote += `\n\n${QUOTE_SEPARATOR}\n\n`; + } + + return quote; + } + + // Function to add the quote to the text area + function addQuoteToTextArea(quote) { + const textArea = document.querySelector('.ProseMirror'); + if (!textArea) return; + + // Focus the editor + textArea.focus(); + + // Clear the editor if it's empty + if (textArea.querySelector('.empty-node')) { + textArea.innerHTML = '

'; + } + + // Try multiple methods to insert text + try { + // Try using execCommand + document.execCommand('insertText', false, quote); + } catch (e) { + console.log("execCommand method failed:", e); + + // Fallback to creating a text node + try { + const selection = window.getSelection(); + if (selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + const textNode = document.createTextNode(quote); + range.insertNode(textNode); + } else { + // If no selection, add to the beginning + const p = textArea.querySelector('p') || textArea; + if (p.firstChild) { + p.insertBefore(document.createTextNode(quote), p.firstChild); + } else { + p.appendChild(document.createTextNode(quote)); + } + } + } catch (err) { + console.error("Failed to insert text:", err); + } + } + } + + // Function to create and show configuration popup + function showConfigPopup(x, y) { + // Remove any existing popup + const existingPopup = document.getElementById('chatwoot-quote-config'); + if (existingPopup) { + existingPopup.remove(); + } + + // Create popup container + const popup = document.createElement('div'); + popup.id = 'chatwoot-quote-config'; + popup.style.position = 'fixed'; + popup.style.zIndex = '9999'; + popup.style.padding = '10px'; + popup.style.backgroundColor = 'white'; + popup.style.border = '1px solid #ccc'; + popup.style.borderRadius = '4px'; + popup.style.boxShadow = '0 2px 10px rgba(0, 0, 0, 0.1)'; + popup.style.width = '250px'; + + // Add title + const title = document.createElement('h3'); + title.textContent = t('quote_config'); + title.style.margin = '0 0 10px 0'; + title.style.fontSize = '14px'; + title.style.fontWeight = 'bold'; + popup.appendChild(title); + + // Add max messages configuration + const maxMessagesLabel = document.createElement('label'); + maxMessagesLabel.textContent = t('max_messages') + ' '; + maxMessagesLabel.style.display = 'block'; + maxMessagesLabel.style.marginBottom = '5px'; + maxMessagesLabel.style.fontSize = '12px'; + popup.appendChild(maxMessagesLabel); + + const maxMessagesInput = document.createElement('input'); + maxMessagesInput.type = 'number'; + maxMessagesInput.min = '1'; + maxMessagesInput.max = '20'; + maxMessagesInput.value = config.maxMessages; + maxMessagesInput.style.width = '100%'; + maxMessagesInput.style.marginBottom = '10px'; + maxMessagesInput.style.padding = '5px'; + maxMessagesInput.style.boxSizing = 'border-box'; + popup.appendChild(maxMessagesInput); + + // Add language selector + const languageLabel = document.createElement('label'); + languageLabel.textContent = t('language') + ' '; + languageLabel.style.display = 'block'; + languageLabel.style.marginBottom = '5px'; + languageLabel.style.fontSize = '12px'; + popup.appendChild(languageLabel); + + const languageSelect = document.createElement('select'); + languageSelect.style.width = '100%'; + languageSelect.style.marginBottom = '10px'; + languageSelect.style.padding = '5px'; + languageSelect.style.boxSizing = 'border-box'; + + const languages = [ + { value: 'en', label: t('english') }, + { value: 'de', label: t('german') } + ]; + + languages.forEach(lang => { + const option = document.createElement('option'); + option.value = lang.value; + option.textContent = lang.label; + if (config.language === lang.value) { + option.selected = true; + } + languageSelect.appendChild(option); + }); + + popup.appendChild(languageSelect); + + // Add show exclusion indicator checkbox + const showExclusionLabel = document.createElement('label'); + showExclusionLabel.textContent = t('show_exclusion') + ' '; + showExclusionLabel.style.display = 'block'; + showExclusionLabel.style.marginBottom = '5px'; + showExclusionLabel.style.fontSize = '12px'; + popup.appendChild(showExclusionLabel); + + const showExclusionCheckbox = document.createElement('input'); + showExclusionCheckbox.type = 'checkbox'; + showExclusionCheckbox.checked = config.showExclusionIndicator; + showExclusionCheckbox.style.marginBottom = '10px'; + showExclusionCheckbox.style.marginRight = '5px'; + popup.appendChild(showExclusionCheckbox); + + // Add a line break after the checkbox + popup.appendChild(document.createElement('br')); + + // Add auto-open participants checkbox + const autoOpenLabel = document.createElement('label'); + autoOpenLabel.textContent = t('auto_open') + ' '; + autoOpenLabel.style.display = 'block'; + autoOpenLabel.style.marginBottom = '5px'; + autoOpenLabel.style.fontSize = '12px'; + popup.appendChild(autoOpenLabel); + + const autoOpenCheckbox = document.createElement('input'); + autoOpenCheckbox.type = 'checkbox'; + autoOpenCheckbox.checked = config.autoOpenParticipants; + autoOpenCheckbox.style.marginBottom = '10px'; + autoOpenCheckbox.style.marginRight = '5px'; + popup.appendChild(autoOpenCheckbox); + + // Add a line break after the checkbox + popup.appendChild(document.createElement('br')); + + // Add save button + const saveButton = document.createElement('button'); + saveButton.textContent = t('save'); + saveButton.style.padding = '5px 10px'; + saveButton.style.backgroundColor = '#4f46e5'; + saveButton.style.color = 'white'; + saveButton.style.border = 'none'; + saveButton.style.borderRadius = '4px'; + saveButton.style.cursor = 'pointer'; + saveButton.style.marginRight = '5px'; + + saveButton.addEventListener('click', function() { + config.maxMessages = parseInt(maxMessagesInput.value, 10) || 5; + config.language = languageSelect.value; + config.showExclusionIndicator = showExclusionCheckbox.checked; + config.autoOpenParticipants = autoOpenCheckbox.checked; + saveConfig(); + popup.remove(); + }); + + popup.appendChild(saveButton); + + // Add cancel button + const cancelButton = document.createElement('button'); + cancelButton.textContent = t('cancel'); + cancelButton.style.padding = '5px 10px'; + cancelButton.style.backgroundColor = '#f3f4f6'; + cancelButton.style.color = '#374151'; + cancelButton.style.border = '1px solid #d1d5db'; + cancelButton.style.borderRadius = '4px'; + cancelButton.style.cursor = 'pointer'; + + cancelButton.addEventListener('click', function() { + popup.remove(); + }); + + popup.appendChild(cancelButton); + + // Calculate popup height to position it above the click + // Need to temporarily add it to the DOM to get the height + popup.style.visibility = 'hidden'; + document.body.appendChild(popup); + const popupHeight = popup.offsetHeight; + document.body.removeChild(popup); + popup.style.visibility = 'visible'; + + // Position the popup above the mouse click + popup.style.left = `${x}px`; + popup.style.top = `${Math.max(y - popupHeight - 10, 10)}px`; // Ensure it doesn't go off the top of the screen + + // Close popup when clicking outside + document.addEventListener('click', function closePopup(e) { + if (!popup.contains(e.target) && e.target.id !== 'chatwoot-quote-btn') { + popup.remove(); + document.removeEventListener('click', closePopup); + } + }); + + document.body.appendChild(popup); + } + + // Function to create and add the quote button + function addQuoteButton() { + waitForElement('[data-v-b5303981] .right-wrap', (rightWrap) => { + // Check if button already exists + if (document.getElementById('chatwoot-quote-btn')) return; + + // Create the button + const quoteButton = document.createElement('button'); + quoteButton.id = 'chatwoot-quote-btn'; + quoteButton.className = 'flex-shrink-0 inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50 bg-n-slate-9/10 text-n-slate-12 hover:enabled:bg-n-slate-9/20 focus-visible:bg-n-slate-9/20 outline-transparent h-8 px-3 text-sm justify-center'; + quoteButton.innerHTML = '|> Quote'; + quoteButton.style.marginRight = '8px'; + + // Add click event + quoteButton.addEventListener('click', async function(e) { + const data = await extractMessages(); + const quote = formatQuote(data); + addQuoteToTextArea(quote); + }); + + // Add right-click event for configuration + quoteButton.addEventListener('contextmenu', function(e) { + e.preventDefault(); + showConfigPopup(e.clientX, e.clientY); + return false; + }); + + // Insert the button before the send button + const sendButton = rightWrap.querySelector('button[type="submit"]'); + if (sendButton) { + rightWrap.insertBefore(quoteButton, sendButton); + } + }); + } + + // Initialize the script + function init() { + // Add button on page load + window.addEventListener('load', function() { + setTimeout(addQuoteButton, 1000); + }); + + // Try adding button immediately + addQuoteButton(); + + // Also check periodically for dynamic page changes + setInterval(addQuoteButton, 3000); + + // Add CSS for the button and popup + const style = document.createElement('style'); + style.textContent = ` + #chatwoot-quote-btn:hover { + background-color: rgba(0, 0, 0, 0.1) !important; + } + + #chatwoot-quote-config { + color: #1f2937; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + } + + #chatwoot-quote-config input:focus, + #chatwoot-quote-config select:focus { + outline: 2px solid #4f46e5; + outline-offset: 1px; + } + + #chatwoot-quote-config button:hover { + opacity: 0.9; + } + `; + document.head.appendChild(style); + } + + init(); +})();