Skip to content

Instantly share code, notes, and snippets.

@heltonteixeira
Forked from volkanunsal/README.md
Last active November 11, 2025 00:32
Show Gist options
  • Select an option

  • Save heltonteixeira/116237abb54e2b55d519056a31d5d6b4 to your computer and use it in GitHub Desktop.

Select an option

Save heltonteixeira/116237abb54e2b55d519056a31d5d6b4 to your computer and use it in GitHub Desktop.
Copy Note to Markdown is a userscript that adds a button to copy the content of a note in markdown format in a Google NotebookLM note.

Copy Note to Markdown

Copy Note to Markdown is a userscript that adds a button to copy the content of a note in markdown format in a Google NotebookLM note.

Key Features

  • Adds a button to the top right corner of the note panel. Clicking this button copies the note content as markdown to the clipboard.
copy-button

2025-11-10

  • Add automatic note title extraction and H1 prepending

2025-11-09

  • Enhance markdown conversion with improved spacing
  • Add support for Tailwind viewer content extraction

2025-11-08

  • Replace custom button with native Material Design components
  • Complete rewrite with SPA navigation and read-only note support

2025-11-07

  • Fix Editor not found errors
  • Fix lint errors

Getting Started

🔧 Installation

  1. Install Tampermonkey browser extension
  2. Click on the "Raw" button of the script below.
  3. Visit notebooklm.google.com to see the magic! ✨
// ==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 ? `![${alt}](${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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment