|
// ==UserScript== |
|
// @name Copy Note to Markdown |
|
// @namespace https://gist.github.com/heltonteixeira |
|
// @version 2025-11-11 |
|
// @description Tampermonkey userscript to copy NotebookLM notes as Markdown |
|
// @author Helton Teixeira |
|
// @downloadURL https://gist.github.com/heltonteixeira/116237abb54e2b55d519056a31d5d6b4/raw/70c3ff0f6d45f1f1c001d0cea3f751bc80efd531/copy-note-to-markdown.user.js |
|
// @updateURL https://gist.github.com/heltonteixeira/116237abb54e2b55d519056a31d5d6b4/raw/70c3ff0f6d45f1f1c001d0cea3f751bc80efd531/copy-note-to-markdown.user.js |
|
// @match https://notebooklm.google.com/* |
|
// @icon https://www.google.com/s2/favicons?sz=64&domain=google.com |
|
// @grant GM_addStyle |
|
// @homepageURL https://github.com/volkanunsal |
|
// @copyright 2025, Helton Teixeira (Forked from Volkan Unsal's original script) |
|
// ==/UserScript== |
|
(function () { |
|
'use strict'; |
|
|
|
/** |
|
* A comprehensive logging utility with timestamps, different log levels, |
|
* grouping, timing, and table output capabilities. |
|
*/ |
|
class Logger { |
|
constructor(config = {}) { |
|
this.namespace = config.namespace ?? ''; |
|
this.prefix = [this.namespace, config.prefix].filter(Boolean).join(' '); |
|
this.enabled = config.enabled !== false; |
|
this.timestamp = config.timestamp !== false; |
|
this.timestampFormat = config.timestampFormat ?? 'locale'; |
|
} |
|
/** |
|
* Get current timestamp based on the configured format |
|
*/ |
|
getTimestamp() { |
|
const now = new Date(); |
|
if (this.timestampFormat === 'ISO') { |
|
return now.toISOString(); |
|
} |
|
return now.toLocaleString(); |
|
} |
|
/** |
|
* Format message with prefix, timestamp, and log level |
|
*/ |
|
formatMessage(level, ...args) { |
|
const parts = [this.prefix]; |
|
if (this.timestamp) { |
|
parts.push(`[${this.getTimestamp()}]`); |
|
} |
|
parts.push(`[${level.toUpperCase()}]`); |
|
return [parts.join(' '), ...args]; |
|
} |
|
/** |
|
* Log debug message |
|
*/ |
|
debug(...args) { |
|
if (this.enabled) { |
|
console.debug(...this.formatMessage('debug', ...args)); |
|
} |
|
} |
|
/** |
|
* Log info message |
|
*/ |
|
info(...args) { |
|
if (this.enabled) { |
|
console.info(...this.formatMessage('info', ...args)); |
|
} |
|
} |
|
/** |
|
* Log warning message |
|
*/ |
|
warn(...args) { |
|
if (this.enabled) { |
|
console.warn(...this.formatMessage('warn', ...args)); |
|
} |
|
} |
|
/** |
|
* Log error message |
|
*/ |
|
error(...args) { |
|
if (this.enabled) { |
|
console.error(...this.formatMessage('error', ...args)); |
|
} |
|
} |
|
/** |
|
* Log generic message |
|
*/ |
|
log(...args) { |
|
if (this.enabled) { |
|
console.log(...this.formatMessage('log', ...args)); |
|
} |
|
} |
|
/** |
|
* Log custom message with custom level |
|
*/ |
|
custom(level, ...args) { |
|
if (this.enabled) { |
|
console.log(...this.formatMessage(level, ...args)); |
|
} |
|
} |
|
/** |
|
* Create a console group and execute callback within it |
|
*/ |
|
group(label, callback) { |
|
if (!this.enabled) { |
|
return callback(); |
|
} |
|
console.group(`${this.prefix} ${label}`); |
|
try { |
|
callback(); |
|
} |
|
finally { |
|
console.groupEnd(); |
|
} |
|
} |
|
/** |
|
* Create a collapsed console group and execute callback within it |
|
*/ |
|
groupCollapsed(label, callback) { |
|
if (!this.enabled) { |
|
return callback(); |
|
} |
|
console.groupCollapsed(`${this.prefix} ${label}`); |
|
try { |
|
callback(); |
|
} |
|
finally { |
|
console.groupEnd(); |
|
} |
|
} |
|
/** |
|
* Log data as a table |
|
*/ |
|
table(data, columns) { |
|
if (this.enabled) { |
|
this.info('Table data:'); |
|
console.table(data, columns); |
|
} |
|
} |
|
/** |
|
* Start a timer |
|
*/ |
|
time(label) { |
|
if (this.enabled) { |
|
console.time(`${this.prefix} ${label}`); |
|
} |
|
} |
|
/** |
|
* End a timer |
|
*/ |
|
timeEnd(label) { |
|
if (this.enabled) { |
|
console.timeEnd(`${this.prefix} ${label}`); |
|
} |
|
} |
|
/** |
|
* Log intermediate timer value |
|
*/ |
|
timeLog(label) { |
|
if (this.enabled) { |
|
console.timeLog(`${this.prefix} ${label}`); |
|
} |
|
} |
|
/** |
|
* Enable the logger |
|
*/ |
|
enable() { |
|
this.enabled = true; |
|
} |
|
/** |
|
* Disable the logger |
|
*/ |
|
disable() { |
|
this.enabled = false; |
|
} |
|
/** |
|
* Update the logger prefix |
|
*/ |
|
setPrefix(prefix) { |
|
this.prefix = prefix; |
|
} |
|
/** |
|
* Check if logger is enabled |
|
*/ |
|
isEnabled() { |
|
return this.enabled; |
|
} |
|
/** |
|
* Get current configuration |
|
*/ |
|
getConfig() { |
|
return { |
|
namespace: this.namespace, |
|
prefix: this.prefix, |
|
enabled: this.enabled, |
|
timestamp: this.timestamp, |
|
timestampFormat: this.timestampFormat, |
|
}; |
|
} |
|
} |
|
/** |
|
* Create a new logger instance with the given configuration |
|
*/ |
|
function createLogger(config = {}) { |
|
return new Logger(config); |
|
} |
|
|
|
/** |
|
* Safely set innerHTML by removing script tags and using Trusted Types if available |
|
*/ |
|
function safeSetInnerHTML(element, html) { |
|
function sanitizeHTML(htmlContent) { |
|
// Remove script tags and their content |
|
return htmlContent.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ''); |
|
} |
|
if (typeof window !== 'undefined' && 'trustedTypes' in window) { |
|
// Use Trusted Types if available for security |
|
const trustedTypes = window.trustedTypes; |
|
const policy = trustedTypes.createPolicy('myHTMLPolicy', { |
|
createHTML: sanitizeHTML, |
|
}); |
|
const trustedHTML = policy.createHTML(html); |
|
element.innerHTML = trustedHTML; |
|
} |
|
else { |
|
// Fallback to sanitized HTML |
|
const sanitizedHTML = sanitizeHTML(html); |
|
element.innerHTML = sanitizedHTML; |
|
} |
|
} |
|
|
|
/** |
|
* CSS selectors for finding elements in the NotebookLM interface |
|
* Updated based on current DOM structure analysis |
|
*/ |
|
// Page detection functions |
|
const PAGE_DETECTION = { |
|
isNotebookPage: () => window.location.pathname.includes('/notebook/'), |
|
isDashboardPage: () => window.location.pathname === '/', |
|
getNotebookId: () => window.location.pathname.match(/\/notebook\/([a-f0-9-]+)/)?.[1], |
|
}; |
|
// Primary editor selectors based on current NotebookLM DOM structure |
|
const EDITOR_SELECTORS = { |
|
// Primary - most reliable selectors in order of preference |
|
primary: [ |
|
'.editor.ql-container', // Quill editor container |
|
'.ql-editor[contenteditable="true"]', // Contenteditable area |
|
'rich-text-editor.note-editor', // Rich text editor wrapper |
|
], |
|
// Fallback selectors for edge cases |
|
fallback: [ |
|
'.ql-editor', // Any Quill editor |
|
'.editor', // Generic editor |
|
'[contenteditable="true"]', // Contenteditable elements |
|
'note-editor', // Generic note editor |
|
'.note-content', // Note content area |
|
], |
|
}; |
|
// Copy button insertion points |
|
const INSERTION_SELECTORS = { |
|
// Best insertion point: inside title container before existing buttons |
|
primary: [ |
|
'.note-title-container', // Insert inside this element before existing buttons |
|
], |
|
fallback: [ |
|
'form.note-form', // Form container as fallback |
|
'note-editor', // Editor wrapper |
|
], |
|
}; |
|
// Content extraction selectors |
|
const CONTENT_SELECTORS = { |
|
primary: 'labs-tailwind-doc-viewer'}; |
|
// Button management |
|
const COPY_BUTTON_SELECTOR = '#copy-note-to-markdown-btn'; |
|
// Note title selectors for extracting note titles |
|
const NOTE_TITLE_SELECTORS = { |
|
primary: [ |
|
'input[aria-label="note title editable"]', // Primary selector from Chrome DevTools analysis |
|
], |
|
fallback: [ |
|
'.note-header__editable-title', // Class-based selector |
|
'.note-header__editable-title-3panel', // 3-panel specific class |
|
'input.note-header__editable-title', // Input with class |
|
'.note-title-container input', // Input within title container |
|
'[data-testid="note-title"]', // Test ID fallback |
|
'input[placeholder*="note title"]', // Placeholder-based fallback |
|
'.note-title input', // Generic input in note title |
|
], |
|
}; |
|
// Navigation and timing |
|
const NAVIGATION_CONFIG = { |
|
checkInterval: 1000, // Check for navigation changes every 1 second |
|
editorPollInterval: 500}; |
|
|
|
/** |
|
* Utility functions for extracting note titles from NotebookLM |
|
*/ |
|
const logger = createLogger({ |
|
namespace: '[TitleExtractor]', |
|
}); |
|
/** |
|
* Extract the note title from the DOM using multiple selectors |
|
* |
|
* @param options - Configuration options for title extraction |
|
* @returns NoteTitleData with extraction results |
|
*/ |
|
function extractNoteTitle(options = {}) { |
|
const { timeout = 3000, includeFallbacks = true, logExtraction = true, } = options; |
|
const startTime = Date.now(); |
|
if (logExtraction) { |
|
logger.debug('Starting note title extraction', { |
|
timeout, |
|
includeFallbacks, |
|
}); |
|
} |
|
// Combine primary and fallback selectors |
|
const allSelectors = [...NOTE_TITLE_SELECTORS.primary]; |
|
if (includeFallbacks) { |
|
allSelectors.push(...NOTE_TITLE_SELECTORS.fallback); |
|
} |
|
// Try each selector in order |
|
for (let i = 0; i < allSelectors.length; i++) { |
|
const selector = allSelectors[i]; |
|
if (!selector) |
|
continue; |
|
const element = document.querySelector(selector); |
|
if (element && |
|
element instanceof HTMLInputElement && |
|
typeof element.value === 'string' && |
|
element.value) { |
|
const elapsedTime = Date.now() - startTime; |
|
const isFallback = i >= NOTE_TITLE_SELECTORS.primary.length; |
|
if (logExtraction) { |
|
logger.info('Note title extracted successfully', { |
|
title: element.value, |
|
selector, |
|
isFallback, |
|
elapsedTime, |
|
}); |
|
} |
|
return { |
|
title: element.value.trim() || null, |
|
element, |
|
selectorUsed: selector || null, |
|
extracted: true, |
|
fallbackUsed: isFallback, |
|
elapsedTime, |
|
}; |
|
} |
|
} |
|
const elapsedTime = Date.now() - startTime; |
|
if (logExtraction) { |
|
logger.warn('No note title found', { |
|
selectorsTried: allSelectors.length, |
|
elapsedTime, |
|
}); |
|
} |
|
return { |
|
title: null, |
|
element: null, |
|
selectorUsed: null, |
|
extracted: false, |
|
fallbackUsed: false, |
|
elapsedTime, |
|
}; |
|
} |
|
/** |
|
* Sanitize title for use in Markdown |
|
* |
|
* @param title - The title to sanitize |
|
* @returns Sanitized title safe for Markdown |
|
*/ |
|
function sanitizeTitleForMarkdown(title) { |
|
return (title |
|
.trim() |
|
// Remove excessive whitespace |
|
.replace(/\s+/g, ' ') |
|
// Escape Markdown special characters in the title |
|
.replace(/[#`*_[\]()]|[<>]/g, (match) => { |
|
const escapeMap = { |
|
'#': '\\#', |
|
'`': '\\`', |
|
'*': '\\*', |
|
_: '\\_', |
|
'[': '\\[', |
|
']': '\\]', |
|
'(': '\\(', |
|
')': '\\)', |
|
'<': '\\<', |
|
'>': '\\>', |
|
}; |
|
return escapeMap[match] ?? match; |
|
})); |
|
} |
|
/** |
|
* Check if a title is meaningful (not empty or placeholder) |
|
* |
|
* @param title - The title to validate |
|
* @returns true if the title is meaningful |
|
*/ |
|
function isValidTitle(title) { |
|
if (!title || typeof title !== 'string') { |
|
return false; |
|
} |
|
const trimmed = title.trim(); |
|
// Check if it's too short |
|
if (trimmed.length < 1) { |
|
return false; |
|
} |
|
// Check for placeholder text |
|
const placeholders = [ |
|
'untitled', |
|
'new note', |
|
'note title', |
|
'add title', |
|
'title', |
|
]; |
|
return !placeholders.some((placeholder) => trimmed.toLowerCase() === placeholder); |
|
} |
|
|
|
/** |
|
* Set of HTML5 block-level elements that should create spacing |
|
*/ |
|
const BLOCK_ELEMENTS = new Set([ |
|
'address', |
|
'article', |
|
'aside', |
|
'blockquote', |
|
'canvas', |
|
'dd', |
|
'div', |
|
'dl', |
|
'dt', |
|
'fieldset', |
|
'figcaption', |
|
'figure', |
|
'footer', |
|
'form', |
|
'h1', |
|
'h2', |
|
'h3', |
|
'h4', |
|
'h5', |
|
'h6', |
|
'header', |
|
'hr', |
|
'li', |
|
'main', |
|
'nav', |
|
'noscript', |
|
'ol', |
|
'p', |
|
'pre', |
|
'section', |
|
'table', |
|
'tfoot', |
|
'ul', |
|
'video', |
|
'details', |
|
'summary', |
|
// NotebookLM custom elements |
|
'labs-tailwind-structural-element-view-v2', |
|
'labs-tailwind-doc-viewer', |
|
]); |
|
/** |
|
* Check if an HTML element is a block-level element |
|
*/ |
|
function isBlockElement(tagName) { |
|
return BLOCK_ELEMENTS.has(tagName.toLowerCase()); |
|
} |
|
/** |
|
* Determine spacing needed between elements based on their types |
|
*/ |
|
function getSpacingBetweenElements(previousWasBlock, currentElementIsBlock, inList = false) { |
|
// Inside lists, we use different spacing rules |
|
if (inList) { |
|
return ''; |
|
} |
|
// No spacing needed for the very first element |
|
if (!previousWasBlock) { |
|
return ''; |
|
} |
|
// If current element is a block and previous was also a block, add blank line |
|
if (currentElementIsBlock && previousWasBlock) { |
|
return '\n\n'; |
|
} |
|
// Otherwise, no additional spacing needed |
|
return ''; |
|
} |
|
/** |
|
* Clean and preprocess HTML string before conversion |
|
*/ |
|
function cleanHTML(html) { |
|
if (!html || typeof html !== 'string') { |
|
return ''; |
|
} |
|
// Remove empty elements but preserve structure |
|
let cleaned = html.trim(); |
|
cleaned = cleaned.replace(/<(\w+)[^>]*>\s*<\/\1>/g, ''); |
|
// Create temporary element to process HTML |
|
const tempDiv = document.createElement('div'); |
|
safeSetInnerHTML(tempDiv, cleaned); |
|
// Convert to markdown with context awareness |
|
const markdown = convertElementToMarkdown(tempDiv, { |
|
listDepth: 0, |
|
inList: false, |
|
previousWasBlock: false, |
|
currentElementIsBlock: false, |
|
}); |
|
// Final cleanup - normalize excessive newlines |
|
let result = markdown.trim(); |
|
// Replace 3+ consecutive newlines with exactly 2 newlines |
|
result = result.replace(/\n{3,}/g, '\n\n'); |
|
// Remove leading/trailing newlines |
|
result = result.replace(/^\n+|\n+$/g, ''); |
|
return result; |
|
} |
|
/** |
|
* Process NotebookLM's custom structural elements |
|
*/ |
|
function processNotebookLMElement(element, options, spacing) { |
|
// First, check if this element contains HTML tables |
|
const tables = element.querySelectorAll('table'); |
|
if (tables.length > 0) { |
|
// Process all child nodes including tables, but let the main converter handle tables naturally |
|
const allContent = processChildNodes(Array.from(element.childNodes), options); |
|
return spacing ? `${spacing}${allContent}` : allContent; |
|
} |
|
// Find the actual content div inside the custom element |
|
const contentDiv = element.querySelector('div.paragraph'); |
|
if (!contentDiv) { |
|
return processChildNodes(Array.from(element.childNodes), options); |
|
} |
|
const className = contentDiv.className; |
|
const textContent = contentDiv.textContent?.trim() || ''; |
|
// Clone the content div and remove citation buttons |
|
const cleanDiv = contentDiv.cloneNode(true); |
|
const citationButtons = cleanDiv.querySelectorAll('button[class*="citation"], .xap-inline-dialog'); |
|
citationButtons.forEach((button) => button.remove()); |
|
const content = processChildNodes(Array.from(cleanDiv.childNodes), options); |
|
// Check if this looks like a list item despite being marked as a heading |
|
const isListItem = /^\d+\./.test(textContent) || /^[•\-*]/.test(textContent); |
|
// Handle different paragraph types based on CSS classes and content patterns |
|
if (isListItem) { |
|
// This looks like a list item, format it as such |
|
const indent = ' '.repeat(options.listDepth ?? 0); |
|
if (/^\d+\./.test(textContent)) { |
|
// Numbered list item |
|
const match = textContent.match(/^(\d+)\./); |
|
const number = match ? match[1] : '1'; |
|
const contentWithoutNumber = content.replace(/^\d+\./, '').trim(); |
|
return `${spacing}${indent}${number}. ${contentWithoutNumber}`; |
|
} |
|
// Bullet list item |
|
const contentWithoutBullet = content.replace(/^[•\-*]\s*/, '').trim(); |
|
return `${spacing}${indent}- ${contentWithoutBullet}`; |
|
} |
|
else if (className.includes('heading1')) { |
|
return `${spacing}# ${content}`; |
|
} |
|
else if (className.includes('heading2')) { |
|
return `${spacing}## ${content}`; |
|
} |
|
else if (className.includes('heading3')) { |
|
return `${spacing}### ${content}`; |
|
} |
|
else if (className.includes('heading4')) { |
|
return `${spacing}#### ${content}`; |
|
} |
|
else if (className.includes('heading5')) { |
|
return `${spacing}##### ${content}`; |
|
} |
|
else if (className.includes('heading6')) { |
|
return `${spacing}###### ${content}`; |
|
} |
|
else if (className.includes('normal')) { |
|
return `${spacing}${content}`; |
|
} |
|
// Default to treating as a paragraph |
|
return `${spacing}${content}`; |
|
} |
|
/** |
|
* Convert an element and its children to markdown recursively |
|
*/ |
|
function convertElementToMarkdown(element, options = {}) { |
|
const defaultOptions = { |
|
listDepth: 0, |
|
inList: false, |
|
previousWasBlock: false, |
|
currentElementIsBlock: false, |
|
}; |
|
const mergedOptions = { ...defaultOptions, ...options }; |
|
if (!element) { |
|
return ''; |
|
} |
|
// Handle text nodes |
|
if (element.nodeType === Node.TEXT_NODE) { |
|
const text = element.textContent || ''; |
|
return escapeMarkdownText(text); |
|
} |
|
// Handle element nodes |
|
if (element.nodeType === Node.ELEMENT_NODE) { |
|
const tagName = element.tagName.toLowerCase(); |
|
const childNodes = Array.from(element.childNodes); |
|
const isCurrentBlock = isBlockElement(tagName); |
|
// Add spacing before this element if needed |
|
const spacing = getSpacingBetweenElements(mergedOptions.previousWasBlock ?? false, isCurrentBlock, mergedOptions.inList ?? false); |
|
// Update options for this element |
|
const elementOptions = { |
|
...mergedOptions, |
|
currentElementIsBlock: isCurrentBlock, |
|
}; |
|
switch (tagName) { |
|
// Headers |
|
case 'h1': |
|
return `${spacing}# ${getElementTextContent(element)}\n`; |
|
case 'h2': |
|
return `${spacing}## ${getElementTextContent(element)}\n`; |
|
case 'h3': |
|
return `${spacing}### ${getElementTextContent(element)}\n`; |
|
case 'h4': |
|
return `${spacing}#### ${getElementTextContent(element)}\n`; |
|
case 'h5': |
|
return `${spacing}##### ${getElementTextContent(element)}\n`; |
|
case 'h6': |
|
return `${spacing}###### ${getElementTextContent(element)}\n`; |
|
// Paragraph |
|
case 'p': { |
|
const paragraphContent = processChildNodes(childNodes, elementOptions); |
|
return paragraphContent ? `${spacing}${paragraphContent}\n` : ''; |
|
} |
|
// Text formatting (inline elements - no spacing) |
|
case 'strong': |
|
case 'b': // fallthrough |
|
return `**${processChildNodes(childNodes, elementOptions)}**`; |
|
case 'em': |
|
case 'i': // fallthrough |
|
return `*${processChildNodes(childNodes, elementOptions)}*`; |
|
case 'code': |
|
return `\`${element.textContent || ''}\``; |
|
// Preformatted text (code blocks) |
|
case 'pre': |
|
return `${spacing}\`\`\`\n${element.textContent || ''}\n\`\`\`\n`; |
|
// Blockquote |
|
case 'blockquote': |
|
return `${spacing}${processChildNodes(childNodes, elementOptions) |
|
.split('\n') |
|
.map((line) => `> ${line}`) |
|
.join('\n') + '\n'}`; |
|
// Lists (handled by separate functions) |
|
case 'ul': |
|
return `${spacing}${processUnorderedList(childNodes, elementOptions)}`; |
|
case 'ol': |
|
return `${spacing}${processOrderedList(childNodes, elementOptions)}`; |
|
case 'li': |
|
return processListItem(childNodes, elementOptions); |
|
// Links and images (inline elements) |
|
case 'a': { |
|
const href = element.getAttribute('href'); |
|
const linkText = processChildNodes(childNodes, elementOptions); |
|
return href ? `[${linkText}](${href})` : linkText; |
|
} |
|
case 'img': { |
|
const src = element.getAttribute('src'); |
|
const alt = element.getAttribute('alt') ?? ''; |
|
return src ? `` : ''; |
|
} |
|
// Line breaks and horizontal rules |
|
case 'br': |
|
return '\n'; |
|
case 'hr': |
|
return `${spacing}---\n`; |
|
// Tables |
|
case 'table': |
|
return `${spacing}${processTable(element, elementOptions)}`; |
|
// Generic containers (handle Quill editor indentation) |
|
case 'div': |
|
case 'span': |
|
case 'section': |
|
case 'article': // fallthrough |
|
if (element.className?.includes('ql-indent')) { |
|
const indentLevel = getIndentLevel(element.className); |
|
return processChildNodes(childNodes, { |
|
...elementOptions, |
|
listDepth: indentLevel, |
|
}); |
|
} |
|
return isCurrentBlock |
|
? `${spacing}${processChildNodes(childNodes, elementOptions)}` |
|
: processChildNodes(childNodes, elementOptions); |
|
// Other semantic and formatting elements |
|
case 'kbd': |
|
return `\`${getElementTextContent(element)}\``; |
|
case 'del': |
|
case 's': // fallthrough |
|
return `~~${processChildNodes(childNodes, elementOptions)}~~`; |
|
case 'sup': |
|
return `^${processChildNodes(childNodes, elementOptions)}^`; |
|
case 'sub': |
|
return `~${processChildNodes(childNodes, elementOptions)}~`; |
|
case 'mark': |
|
return `**${processChildNodes(childNodes, elementOptions)}**`; |
|
case 'u': |
|
return processChildNodes(childNodes, elementOptions); |
|
case 'q': |
|
return `"${processChildNodes(childNodes, elementOptions)}"`; |
|
case 'abbr': |
|
case 'acronym': { |
|
// fallthrough |
|
const title = element.getAttribute('title'); |
|
const text = processChildNodes(childNodes, elementOptions); |
|
return title ? `${text} (${title})` : text; |
|
} |
|
case 'time': |
|
return processChildNodes(childNodes, elementOptions); |
|
case 'small': |
|
return processChildNodes(childNodes, elementOptions); |
|
case 'cite': |
|
return `*${processChildNodes(childNodes, elementOptions)}*`; |
|
case 'address': |
|
return `${spacing}${processChildNodes(childNodes, elementOptions)}\n`; |
|
case 'details': |
|
return `${spacing}${processChildNodes(childNodes, elementOptions)}\n`; |
|
case 'summary': |
|
return `${spacing}**${processChildNodes(childNodes, elementOptions)}**\n`; |
|
case 'figure': |
|
return `${spacing}${processChildNodes(childNodes, elementOptions)}\n`; |
|
case 'figcaption': |
|
return `*${processChildNodes(childNodes, elementOptions)}*\n`; |
|
// NotebookLM custom elements |
|
case 'labs-tailwind-doc-viewer': |
|
return `${spacing}${processChildNodes(childNodes, elementOptions)}`; |
|
case 'labs-tailwind-structural-element-view-v2': |
|
return processNotebookLMElement(element, elementOptions, spacing); |
|
// Default case |
|
default: |
|
return isCurrentBlock |
|
? `${spacing}${processChildNodes(childNodes, elementOptions)}` |
|
: processChildNodes(childNodes, elementOptions); |
|
} |
|
} |
|
return ''; |
|
} |
|
/** |
|
* Process child nodes and convert them to markdown |
|
*/ |
|
function processChildNodes(childNodes, options) { |
|
let result = ''; |
|
let previousWasBlock = options.previousWasBlock ?? false; |
|
for (const child of childNodes) { |
|
// Update options for this child with block context |
|
const childOptions = { |
|
...options, |
|
previousWasBlock, |
|
}; |
|
const childMarkdown = convertElementToMarkdown(child, childOptions); |
|
if (childMarkdown) { |
|
// Update tracking for next iteration |
|
if (child.nodeType === Node.ELEMENT_NODE) { |
|
const tagName = child.tagName.toLowerCase(); |
|
previousWasBlock = isBlockElement(tagName); |
|
} |
|
result += childMarkdown; |
|
} |
|
} |
|
return result; |
|
} |
|
/** |
|
* Get clean text content from an element |
|
*/ |
|
function getElementTextContent(element) { |
|
return (element.textContent || '').replace(/\s+/g, ' ').trim(); |
|
} |
|
/** |
|
* Escape markdown special characters in text |
|
*/ |
|
function escapeMarkdownText(text) { |
|
if (!text) { |
|
return ''; |
|
} |
|
// Don't normalize whitespace - preserve meaningful line breaks and spacing |
|
let escaped = text; |
|
// Only escape backticks and backslashes if needed |
|
if (escaped) { |
|
escaped = escaped.replace(/([\\`])/g, '\\$1'); |
|
} |
|
return escaped; |
|
} |
|
/** |
|
* Get Quill editor indentation level from class name |
|
*/ |
|
function getIndentLevel(className) { |
|
const match = className.match(/ql-indent-(\d+)/); |
|
return match ? parseInt(match[1], 10) : 0; |
|
} |
|
/** |
|
* Process unordered list |
|
*/ |
|
function processUnorderedList(childNodes, options) { |
|
const listOptions = { |
|
...options, |
|
inList: true, |
|
listDepth: (options.listDepth ?? 0) + 1, |
|
}; |
|
const listItems = childNodes |
|
.filter((node) => node.nodeType === Node.ELEMENT_NODE && |
|
node.tagName.toLowerCase() === 'li') |
|
.map((liElement) => { |
|
const element = liElement; |
|
let indentOffset = 0; |
|
// Handle Quill editor indentation |
|
if (element.className) { |
|
const indentMatch = element.className.match(/ql-indent-(\d+)/); |
|
if (indentMatch) { |
|
indentOffset = parseInt(indentMatch[1], 10); |
|
} |
|
} |
|
const finalIndentLevel = Math.max(0, (options.listDepth ?? 0) + indentOffset); |
|
const indent = ' '.repeat(finalIndentLevel); |
|
const childNodesArray = Array.from(element.childNodes); |
|
const content = processListItem(childNodesArray, listOptions); |
|
return `${indent}- ${content}`; |
|
}) |
|
.filter((item) => item.trim()); |
|
return listItems.length > 0 ? listItems.join('\n') : ''; |
|
} |
|
/** |
|
* Process ordered list |
|
*/ |
|
function processOrderedList(childNodes, options) { |
|
const listOptions = { |
|
...options, |
|
inList: true, |
|
listDepth: (options.listDepth ?? 0) + 1, |
|
}; |
|
const listItems = childNodes |
|
.filter((node) => node.nodeType === Node.ELEMENT_NODE && |
|
node.tagName.toLowerCase() === 'li') |
|
.map((liElement, index) => { |
|
const element = liElement; |
|
let indentOffset = 0; |
|
// Handle Quill editor indentation |
|
if (element.className) { |
|
const indentMatch = element.className.match(/ql-indent-(\d+)/); |
|
if (indentMatch) { |
|
indentOffset = parseInt(indentMatch[1], 10); |
|
} |
|
} |
|
const finalIndentLevel = Math.max(0, (options.listDepth ?? 0) + indentOffset); |
|
const indent = ' '.repeat(finalIndentLevel); |
|
const childNodesArray = Array.from(element.childNodes); |
|
const content = processListItem(childNodesArray, listOptions); |
|
return `${indent}${index + 1}. ${content}`; |
|
}) |
|
.filter((item) => item.trim()); |
|
return listItems.length > 0 ? listItems.join('\n') : ''; |
|
} |
|
/** |
|
* Process list item content |
|
*/ |
|
function processListItem(childNodes, options) { |
|
const content = processChildNodes(childNodes, options); |
|
return content.replace(/^\s+|\s+$/g, '').replace(/\n\n+/g, '\n'); |
|
} |
|
/** |
|
* Process HTML table |
|
*/ |
|
function processTable(element, options) { |
|
const rows = Array.from(element.querySelectorAll('tr')); |
|
if (rows.length === 0) { |
|
return ''; |
|
} |
|
const markdownRows = rows.map((row) => { |
|
const cells = Array.from(row.querySelectorAll('td, th')); |
|
const markdownCells = cells.map((cell) => { |
|
// Process all child nodes of the cell, including custom elements like |
|
// labs-tailwind-structural-element-view-v2, instead of just getting textContent |
|
const cellContent = processChildNodes(Array.from(cell.childNodes), options).trim(); |
|
return ` ${cellContent} `; |
|
}); |
|
return `|${markdownCells.join('|')}|`; |
|
}); |
|
// Add header separator after the first row |
|
// Check if first row has th elements, otherwise treat first row as header |
|
if (rows.length > 0 && rows[0]) { |
|
const firstRow = rows[0]; |
|
const headerCells = firstRow.querySelectorAll('th, td'); |
|
const headerCellCount = headerCells.length; |
|
const separator = `|${Array(headerCellCount).fill(' --- ').join('|')}|`; |
|
markdownRows.splice(1, 0, separator); |
|
} |
|
return markdownRows.join('\n') + '\n'; |
|
} |
|
/** |
|
* Check if the markdown content contains any H1 headers |
|
* |
|
* @param markdown - The markdown content to check |
|
* @returns true if H1 headers are present |
|
*/ |
|
function hasH1Header(markdown) { |
|
// Check for H1 headers using various markdown patterns |
|
const h1Patterns = [ |
|
/^#\s+/m, // # Header |
|
/^===+$/m, // Underline style H1 |
|
]; |
|
return h1Patterns.some((pattern) => pattern.test(markdown)); |
|
} |
|
/** |
|
* Prepend a title as H1 to markdown content |
|
* |
|
* @param markdown - The original markdown content |
|
* @param title - The title to prepend |
|
* @param options - Configuration options for title prepending |
|
* @returns Markdown with title prepended as H1 |
|
*/ |
|
function prependTitleAsH1(markdown, title, options = {}) { |
|
const { sanitizeTitle = true, addBlankLine = true, preserveExistingH1 = true, } = options; |
|
// If preserveExistingH1 is true and H1 already exists, return original |
|
if (preserveExistingH1 && hasH1Header(markdown)) { |
|
return markdown; |
|
} |
|
// Sanitize the title for markdown if requested |
|
const safeTitle = sanitizeTitle ? sanitizeTitleForMarkdown(title) : title; |
|
// Create the H1 header |
|
const h1Header = `# ${safeTitle}`; |
|
const separator = addBlankLine ? '\n\n' : '\n'; |
|
// Combine with existing content |
|
const trimmedMarkdown = markdown.trim(); |
|
const result = trimmedMarkdown |
|
? `${h1Header}${separator}${trimmedMarkdown}` |
|
: h1Header; |
|
return result; |
|
} |
|
|
|
/** |
|
* CSS styles for the copy button and other UI elements |
|
*/ |
|
// Note: Button styling now handled by Material Design classes (mdc-icon-button, mat-mdc-icon-button, etc.) |
|
// No custom button styles needed since we're using native NotebookLM Material Design components |
|
// Notice container modifications to ensure proper layout |
|
const NOTICE_CONTAINER_STYLES = ` |
|
.note-header__notices { |
|
display: flex !important; |
|
justify-content: space-between !important; |
|
align-items: center !important; |
|
} |
|
|
|
.note-header__notices:empty { |
|
display: none !important; |
|
} |
|
`; |
|
// Button configuration - Material Design classes handle all styling |
|
const BUTTON_CONFIG = { |
|
id: 'copy-note-to-markdown-btn', |
|
title: 'Copy Note as Markdown', |
|
// No custom style needed - Material Design classes handle everything |
|
}; |
|
// Default mat-icon styling to match native NotebookLM buttons |
|
const MAT_ICON_STYLES = ` |
|
button#copy-note-to-markdown-btn mat-icon { |
|
color: #fff !important; |
|
} |
|
`; |
|
// Animation styles for feedback - works with Material Design structure |
|
const ANIMATION_STYLES = ` |
|
@keyframes copy-success { |
|
0% { |
|
background-color: rgba(0, 0, 0, 0); |
|
} |
|
50% { |
|
background-color: rgba(52, 168, 83, 0.12); |
|
} |
|
100% { |
|
background-color: rgba(0, 0, 0, 0); |
|
} |
|
} |
|
|
|
button#copy-note-to-markdown-btn.copy-success { |
|
animation: copy-success 0.6s ease-in-out; |
|
} |
|
|
|
button#copy-note-to-markdown-btn.copy-success mat-icon { |
|
color: rgba(52, 168, 83, 0.87) !important; |
|
} |
|
|
|
@keyframes copy-error { |
|
0% { |
|
background-color: rgba(0, 0, 0, 0); |
|
} |
|
50% { |
|
background-color: rgba(234, 67, 53, 0.12); |
|
} |
|
100% { |
|
background-color: rgba(0, 0, 0, 0); |
|
} |
|
} |
|
|
|
button#copy-note-to-markdown-btn.copy-error { |
|
animation: copy-error 0.6s ease-in-out; |
|
} |
|
|
|
button#copy-note-to-markdown-btn.copy-error mat-icon { |
|
color: rgba(234, 67, 53, 0.87) !important; |
|
} |
|
`; |
|
// Combined styles for injection |
|
const ALL_STYLES = ` |
|
${NOTICE_CONTAINER_STYLES} |
|
${MAT_ICON_STYLES} |
|
${ANIMATION_STYLES} |
|
`; |
|
|
|
/** |
|
* Copy button component for NotebookLM with proper timing and navigation handling |
|
*/ |
|
class CopyButton { |
|
constructor() { |
|
this.logger = createLogger({ |
|
prefix: 'UserScript', |
|
namespace: '[CopyNoteToMarkdown]', |
|
}); |
|
this.currentNotebookId = null; |
|
this.editorWatcherInterval = null; |
|
this.navigationWatcherInterval = null; |
|
this.buttonCreationFailed = false; |
|
this.lastFailedAttempt = 0; |
|
} |
|
/** |
|
* Initialize the copy button system with proper navigation handling |
|
*/ |
|
async initialize() { |
|
this.logger.info('Initializing NotebookLM Copy to Markdown script'); |
|
// Start navigation monitoring |
|
this.startNavigationMonitoring(); |
|
// Handle initial page load |
|
await this.handlePageChange(); |
|
} |
|
/** |
|
* Start monitoring for SPA navigation changes |
|
*/ |
|
startNavigationMonitoring() { |
|
this.logger.debug('Starting navigation monitoring'); |
|
// Check for navigation changes periodically |
|
this.navigationWatcherInterval = window.setInterval(() => { |
|
this.handlePageChange(); |
|
}, NAVIGATION_CONFIG.checkInterval); |
|
// Also listen to browser navigation events |
|
window.addEventListener('popstate', () => { |
|
this.handlePageChange(); |
|
}); |
|
} |
|
/** |
|
* Handle page changes (dashboard ↔ notebook) |
|
*/ |
|
async handlePageChange() { |
|
const notebookId = PAGE_DETECTION.getNotebookId(); |
|
if (notebookId !== this.currentNotebookId) { |
|
this.currentNotebookId = notebookId ?? null; |
|
this.logger.info(`Page changed - new notebook ID: ${notebookId}`); |
|
// Reset failure flags on navigation |
|
this.buttonCreationFailed = false; |
|
this.lastFailedAttempt = 0; |
|
// Clean up any existing editor watching |
|
this.stopEditorWatching(); |
|
if (notebookId) { |
|
// We're on a notebook page - start watching for editor activation |
|
this.startEditorWatching(); |
|
} |
|
else { |
|
// We're on dashboard - remove any existing copy button |
|
this.removeCopyButton(); |
|
} |
|
} |
|
} |
|
/** |
|
* Start watching for editor to appear when user clicks "Add note" |
|
*/ |
|
startEditorWatching() { |
|
this.logger.info('Starting editor watching for notebook page'); |
|
// Check immediately in case editor is already active |
|
this.checkAndCreateCopyButton(); |
|
// Then set up polling for when editor appears |
|
this.editorWatcherInterval = window.setInterval(() => { |
|
this.checkAndCreateCopyButton(); |
|
}, NAVIGATION_CONFIG.editorPollInterval); |
|
// Also watch for "Add note" button clicks |
|
this.watchForAddNoteButton(); |
|
} |
|
/** |
|
* Stop watching for editor changes |
|
*/ |
|
stopEditorWatching() { |
|
if (this.editorWatcherInterval) { |
|
clearInterval(this.editorWatcherInterval); |
|
this.editorWatcherInterval = null; |
|
this.logger.debug('Stopped editor watching'); |
|
} |
|
} |
|
/** |
|
* Watch for "Add note" button clicks to anticipate editor loading |
|
*/ |
|
watchForAddNoteButton() { |
|
// Use a MutationObserver to watch for new buttons |
|
const observer = new MutationObserver(() => { |
|
const addNoteButtons = document.querySelectorAll('button'); |
|
addNoteButtons.forEach((button) => { |
|
const buttonText = button.textContent?.toLowerCase() ?? ''; |
|
const ariaLabel = button.getAttribute('aria-label')?.toLowerCase() ?? ''; |
|
if (buttonText.includes('add note') || ariaLabel.includes('add note')) { |
|
// Add click listener to anticipate editor loading |
|
if (!button.hasAttribute('data-copy-button-listener')) { |
|
button.setAttribute('data-copy-button-listener', 'true'); |
|
button.addEventListener('click', () => { |
|
this.logger.info('"Add note" button clicked - expecting editor to load'); |
|
// Give editor time to load, then check more frequently |
|
setTimeout(() => { |
|
this.checkAndCreateCopyButton(); |
|
}, 300); |
|
}); |
|
} |
|
} |
|
}); |
|
}); |
|
observer.observe(document.body, { |
|
childList: true, |
|
subtree: true, |
|
}); |
|
} |
|
/** |
|
* Check if editor exists and create copy button if appropriate |
|
*/ |
|
async checkAndCreateCopyButton() { |
|
// Don't proceed if not on notebook page |
|
if (!PAGE_DETECTION.isNotebookPage()) { |
|
return; |
|
} |
|
// Check if copy button already exists |
|
if (this.exists()) { |
|
// Reset failure flag if button now exists |
|
this.buttonCreationFailed = false; |
|
return; |
|
} |
|
// Skip if we've failed recently (prevent infinite loops) |
|
if (this.buttonCreationFailed) { |
|
const timeSinceLastFailure = Date.now() - this.lastFailedAttempt; |
|
// Wait 5 seconds before retrying after a failure |
|
if (timeSinceLastFailure < 5000) { |
|
return; |
|
} |
|
// Reset and try again after 5 seconds |
|
this.buttonCreationFailed = false; |
|
} |
|
// Try to find editor |
|
const editorElement = this.findEditorElement(); |
|
if (!editorElement) { |
|
return; |
|
} |
|
// Check if editor has content |
|
if (!this.hasValidContent(editorElement)) { |
|
return; |
|
} |
|
this.logger.info('Editor found with valid content - creating copy button'); |
|
try { |
|
await this.createCopyButton(); |
|
// Reset failure flag on success |
|
this.buttonCreationFailed = false; |
|
} |
|
catch (error) { |
|
// Mark as failed to prevent infinite loops |
|
this.buttonCreationFailed = true; |
|
this.lastFailedAttempt = Date.now(); |
|
this.logger.error('Copy button creation failed, will retry in 5 seconds:', error); |
|
} |
|
} |
|
/** |
|
* Find editor element using updated selectors |
|
*/ |
|
findEditorElement() { |
|
// Try primary selectors first |
|
for (const selector of EDITOR_SELECTORS.primary) { |
|
const element = document.querySelector(selector); |
|
if (element) { |
|
this.logger.debug(`Found editor with primary selector: ${selector}`); |
|
return element; |
|
} |
|
} |
|
// Try fallback selectors |
|
for (const selector of EDITOR_SELECTORS.fallback) { |
|
const element = document.querySelector(selector); |
|
if (element) { |
|
this.logger.debug(`Found editor with fallback selector: ${selector}`); |
|
return element; |
|
} |
|
} |
|
return null; |
|
} |
|
/** |
|
* Check if this is a read-only note that should have a copy button |
|
*/ |
|
isReadOnlyNote(editorElement) { |
|
// Check for read-only indicators in the DOM |
|
const bodyText = document.body.textContent?.toLowerCase() || ''; |
|
const isReadOnlyIndicator = bodyText.includes('saved responses are view only') || |
|
bodyText.includes('view only') || |
|
bodyText.includes('read only'); |
|
// Check for read-only CSS classes on editor or parent elements |
|
const editorClasses = editorElement.className || ''; |
|
const parentClasses = editorElement.parentElement?.className ?? ''; |
|
const hasReadOnlyClass = editorClasses.includes('readonly') || |
|
editorClasses.includes('read-only') || |
|
parentClasses.includes('readonly') || |
|
parentClasses.includes('read-only'); |
|
// Check for readonly attribute |
|
const isReadonlyAttribute = editorElement.hasAttribute('readonly') || |
|
editorElement.getAttribute('contenteditable') === 'false'; |
|
// Check if this is a labs-tailwind-doc-viewer (typically read-only) |
|
const isTailwindViewer = editorElement.tagName.toLowerCase() === 'labs-tailwind-doc-viewer'; |
|
const isReadOnly = isReadOnlyIndicator || |
|
hasReadOnlyClass || |
|
isReadonlyAttribute || |
|
isTailwindViewer; |
|
this.logger.debug('Read-only note check:', { |
|
hasIndicator: isReadOnlyIndicator, |
|
hasReadOnlyClass, |
|
isReadonlyAttribute, |
|
isTailwindViewer, |
|
isReadOnly, |
|
editorClasses, |
|
parentClasses, |
|
}); |
|
return isReadOnly; |
|
} |
|
/** |
|
* Check if editor element has valid content worth copying AND is read-only |
|
*/ |
|
hasValidContent(editorElement) { |
|
const content = this.extractContentFromElement(editorElement); |
|
const hasContent = Boolean(content && content.trim().length > 5); |
|
// Only show button on read-only notes with content |
|
const isReadOnly = this.isReadOnlyNote(editorElement); |
|
const shouldShowButton = hasContent && isReadOnly; |
|
if (!hasContent) { |
|
this.logger.debug(`Editor found but content too short (${content?.length || 0} characters)`); |
|
} |
|
if (hasContent && !isReadOnly) { |
|
this.logger.debug('Note has content but is editable - no copy button needed'); |
|
} |
|
if (hasContent && isReadOnly) { |
|
this.logger.debug('Read-only note with content found - copy button appropriate'); |
|
} |
|
return shouldShowButton; |
|
} |
|
/** |
|
* Extract content from editor element |
|
*/ |
|
extractContentFromElement(element) { |
|
// Try contenteditable area first |
|
const contentArea = element.querySelector(CONTENT_SELECTORS.primary) ?? |
|
element.querySelector('.ql-editor') ?? |
|
element.querySelector('[contenteditable="true"]'); |
|
if (contentArea) { |
|
return contentArea.innerHTML?.trim() || ''; |
|
} |
|
// Fallback to element's own content |
|
return element.innerHTML?.trim() || ''; |
|
} |
|
/** |
|
* Create the copy button and insert it into the page |
|
*/ |
|
async createCopyButton() { |
|
try { |
|
// Find insertion point |
|
const insertionPoint = this.findInsertionPoint(); |
|
if (!insertionPoint) { |
|
this.logger.warn('Could not find insertion point for copy button'); |
|
return; |
|
} |
|
// Create button with Material Design structure |
|
const button = this.createMaterialDesignButton(); |
|
// Setup click handler |
|
this.setupClickHandler(button); |
|
// Insert the button inside the note-title-container before the first button |
|
const existingButton = insertionPoint.querySelector('button'); |
|
if (existingButton) { |
|
// Insert before the existing button |
|
insertionPoint.insertBefore(button, existingButton); |
|
} |
|
else { |
|
// If no existing button, append to the container |
|
insertionPoint.appendChild(button); |
|
} |
|
this.logger.info('Copy button created successfully'); |
|
} |
|
catch (error) { |
|
this.logger.error('Failed to create copy button:', error); |
|
} |
|
} |
|
/** |
|
* Create Material Design button structure to match NotebookLM buttons |
|
*/ |
|
createMaterialDesignButton() { |
|
// Create main button element with Material Design attributes and classes |
|
const button = document.createElement('button'); |
|
button.id = BUTTON_CONFIG.id; |
|
button.title = BUTTON_CONFIG.title; |
|
button.setAttribute('mat-icon-button', ''); |
|
button.setAttribute('color', 'primary'); |
|
button.setAttribute('mattooltip', BUTTON_CONFIG.title); |
|
button.className = |
|
'mdc-icon-button mat-mdc-icon-button mat-mdc-button-base mat-mdc-tooltip-trigger note-editor-copy-button mat-primary'; |
|
// Create Material Design internal structure |
|
const rippleSpan = document.createElement('span'); |
|
rippleSpan.className = 'mat-mdc-button-persistent-ripple'; |
|
const focusIndicator = document.createElement('span'); |
|
focusIndicator.className = 'mat-focus-indicator'; |
|
// Create the Material icon |
|
const iconElement = document.createElement('mat-icon'); |
|
iconElement.setAttribute('data-mat-icon-type', 'font'); |
|
iconElement.setAttribute('role', 'img'); |
|
iconElement.setAttribute('aria-hidden', 'true'); |
|
iconElement.className = |
|
'mat-icon notranslate google-symbols mat-icon-no-color'; |
|
iconElement.textContent = 'content_copy'; |
|
const touchTarget = document.createElement('span'); |
|
touchTarget.className = 'mat-mdc-button-touch-target'; |
|
// Create ripple element |
|
const ripple = document.createElement('mat-ripple'); |
|
ripple.setAttribute('aria-hidden', 'true'); |
|
ripple.className = 'mat-ripple mat-mdc-button-ripple'; |
|
// Assemble the button structure |
|
button.appendChild(rippleSpan); |
|
button.appendChild(focusIndicator); |
|
button.appendChild(iconElement); |
|
button.appendChild(touchTarget); |
|
button.appendChild(ripple); |
|
return button; |
|
} |
|
/** |
|
* Find the best insertion point for the copy button |
|
*/ |
|
findInsertionPoint() { |
|
// Try primary insertion points |
|
for (const selector of INSERTION_SELECTORS.primary) { |
|
const element = document.querySelector(selector); |
|
if (element) { |
|
this.logger.debug(`Found insertion point with primary selector: ${selector}`); |
|
return element; |
|
} |
|
} |
|
// Try fallback insertion points |
|
for (const selector of INSERTION_SELECTORS.fallback) { |
|
const element = document.querySelector(selector); |
|
if (element) { |
|
this.logger.debug(`Found insertion point with fallback selector: ${selector}`); |
|
return element; |
|
} |
|
} |
|
return null; |
|
} |
|
/** |
|
* Setup click handler for the copy button |
|
*/ |
|
setupClickHandler(button) { |
|
button.addEventListener('click', async (event) => { |
|
event.preventDefault(); |
|
event.stopPropagation(); |
|
try { |
|
await this.handleCopyClick(); |
|
this.showButtonFeedback(button, 'success'); |
|
} |
|
catch (error) { |
|
this.logger.error('Failed to copy note content:', error); |
|
this.showButtonFeedback(button, 'error'); |
|
} |
|
}); |
|
} |
|
/** |
|
* Handle the copy button click event |
|
*/ |
|
async handleCopyClick() { |
|
const editorElement = this.findEditorElement(); |
|
if (!editorElement) { |
|
throw new Error('Editor element not found'); |
|
} |
|
const htmlContent = this.extractContentFromElement(editorElement); |
|
if (!htmlContent) { |
|
throw new Error('No content could be extracted from editor element'); |
|
} |
|
this.logger.debug(`Extracted ${htmlContent.length} characters of HTML content`); |
|
// Convert HTML to Markdown |
|
let markdownContent = cleanHTML(htmlContent); |
|
this.logger.debug(`Converted to ${markdownContent.length} characters of Markdown content`); |
|
// Extract note title and prepend as H1 if no existing H1 |
|
const titleData = extractNoteTitle({ |
|
timeout: 2000, |
|
includeFallbacks: true, |
|
logExtraction: true, |
|
}); |
|
if (titleData.extracted && isValidTitle(titleData.title)) { |
|
const hasH1 = hasH1Header(markdownContent); |
|
this.logger.debug('H1 header detection', { |
|
hasH1, |
|
title: titleData.title, |
|
}); |
|
if (!hasH1) { |
|
markdownContent = prependTitleAsH1(markdownContent, titleData.title, { |
|
sanitizeTitle: true, |
|
addBlankLine: true, |
|
preserveExistingH1: true, |
|
}); |
|
this.logger.info('Note title prepended as H1', { |
|
title: titleData.title, |
|
selector: titleData.selectorUsed, |
|
fallbackUsed: titleData.fallbackUsed, |
|
}); |
|
} |
|
} |
|
else { |
|
this.logger.debug('No valid note title found to prepend', { |
|
titleData, |
|
}); |
|
} |
|
// Copy to clipboard |
|
await navigator.clipboard.writeText(markdownContent); |
|
this.logger.log('Note content copied as Markdown to clipboard'); |
|
} |
|
/** |
|
* Show visual feedback on the button after copy operation |
|
*/ |
|
showButtonFeedback(button, type) { |
|
const originalClass = button.className; |
|
if (type === 'success') { |
|
button.classList.add('copy-success'); |
|
} |
|
else { |
|
button.classList.add('copy-error'); |
|
} |
|
// Remove feedback class after animation completes |
|
setTimeout(() => { |
|
button.className = originalClass; |
|
}, 600); |
|
} |
|
/** |
|
* Check if the copy button already exists |
|
*/ |
|
exists() { |
|
return document.querySelector(COPY_BUTTON_SELECTOR) !== null; |
|
} |
|
/** |
|
* Remove the copy button if it exists |
|
*/ |
|
removeCopyButton() { |
|
const existingButton = document.querySelector(COPY_BUTTON_SELECTOR); |
|
if (existingButton) { |
|
existingButton.remove(); |
|
this.logger.info('Copy button removed'); |
|
} |
|
} |
|
/** |
|
* Clean up all intervals and listeners |
|
*/ |
|
destroy() { |
|
this.stopEditorWatching(); |
|
if (this.navigationWatcherInterval) { |
|
clearInterval(this.navigationWatcherInterval); |
|
this.navigationWatcherInterval = null; |
|
} |
|
this.removeCopyButton(); |
|
this.logger.info('Copy button system destroyed'); |
|
} |
|
} |
|
/** |
|
* Inject CSS styles into the page |
|
*/ |
|
function injectStyles() { |
|
if (typeof GM_addStyle !== 'undefined') { |
|
GM_addStyle(ALL_STYLES); |
|
} |
|
else { |
|
// Fallback for environments without GM_addStyle |
|
const styleElement = document.createElement('style'); |
|
styleElement.textContent = ALL_STYLES; |
|
document.head.appendChild(styleElement); |
|
} |
|
} |
|
|
|
/** |
|
* Main application entry point for the NotebookLM Copy Note to Markdown userscript |
|
*/ |
|
/** |
|
* Main application class |
|
*/ |
|
class NotebookLMCopyScript { |
|
constructor() { |
|
this.logger = createLogger({ |
|
prefix: 'UserScript', |
|
namespace: '[CopyNoteToMarkdown]', |
|
}); |
|
this.copyButton = new CopyButton(); |
|
this.initialize(); |
|
} |
|
/** |
|
* Initialize the application |
|
*/ |
|
async initialize() { |
|
try { |
|
// Inject required styles |
|
this.injectStyles(); |
|
// Initialize the copy button system (handles navigation and timing) |
|
await this.copyButton.initialize(); |
|
this.logger.info('Script initialization completed successfully'); |
|
} |
|
catch (error) { |
|
this.logger.error('Script initialization failed:', error); |
|
} |
|
} |
|
/** |
|
* Inject CSS styles into the page |
|
*/ |
|
injectStyles() { |
|
try { |
|
injectStyles(); |
|
this.logger.debug('Styles injected successfully'); |
|
} |
|
catch (error) { |
|
this.logger.error('Failed to inject styles:', error); |
|
} |
|
} |
|
} |
|
/** |
|
* Start the application when the userscript is loaded |
|
*/ |
|
(function main() { |
|
// Wait for the DOM to be ready |
|
if (document.readyState === 'loading') { |
|
document.addEventListener('DOMContentLoaded', () => { |
|
new NotebookLMCopyScript(); |
|
}); |
|
} |
|
else { |
|
// DOM is already ready |
|
new NotebookLMCopyScript(); |
|
} |
|
})(); |
|
|
|
})(); |
|
//# sourceMappingURL=copy-note-to-markdown.user.js.map |