Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Contribution Templates**: Added GitHub issue forms (bug report, feature request, question/support), issue template configuration, pull request template, and a new `CONTRIBUTING.md` guide to standardize community contributions.
- **Frosted Glass Floating Button**: New "Glass" button style option that renders the floating button as a colorless frosted glass element using `backdrop-filter` blur and saturation, with subtle white borders and inset highlights. Glass is now the default style; users can switch back to solid (accent-colored) in settings.
- **Floating Button Detection Hint**: Added a configurable floating-button hint badge that shows extraction mode (`Article` or `Page`) before click so users can tell what will be copied at a glance.
- **YouTube Chapters Toggle**: Added a new `includeChapters` setting (default: enabled) to include or skip chapter headers and the `## Chapters` section in transcript extraction.

### Fixed

- **YouTube Transcript (New UI)**: Restored transcript extraction for YouTube's updated transcript panel DOM while preserving compatibility with the legacy transcript layout.
- **YouTube Chapters Extraction**: Added chapter extraction support for YouTube's new transcript UI (`timeline-chapter-view-model`), rendering chapters both inline as section headers and as a dedicated `## Chapters` summary section with timestamps.
- **X Video Media Extraction**: Improved X video output by extracting direct video links when available and falling back to a permalink + poster-thumbnail capture for blob-backed video players.
- **Article Detection Notification Defaults**: Switched article-detected info notification to default off in favor of the new floating-button mode hint (still configurable in settings).

Expand Down
60 changes: 54 additions & 6 deletions extension/content/youtube.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ async function waitForTranscriptAndCopy(settings = {}) {
);
throw new Error('Transcript failed to load within timeout period.');
}
let transcriptText = extractTranscriptText(settings.includeTimestamps !== false);
const includeChapters = settings.includeChapters !== false;
let transcriptText = extractTranscriptText(settings.includeTimestamps !== false, includeChapters);
let metaMd = '';
if (
settings.addTitleToTranscript ||
Expand All @@ -90,6 +91,15 @@ async function waitForTranscriptAndCopy(settings = {}) {
if (settings.addUrlToTranscript && videoUrl) metaMd += `**Video URL:** ${videoUrl}\n`;
if (metaMd) metaMd += '\n';
}

if (includeChapters) {
const chapters = extractChapters();
const chaptersMd = formatChaptersSection(chapters);
if (chaptersMd) {
metaMd += chaptersMd + '\n\n';
}
}

transcriptText = metaMd + transcriptText;
const userSettings = await getSettings();
chrome.storage.sync.get(
Expand Down Expand Up @@ -136,24 +146,61 @@ async function waitForTranscriptAndCopy(settings = {}) {
}
}

export function extractTranscriptText(includeTimestamps = true) {
export function extractChapters() {
const chapters = [];

// Old UI: chapter headers inside transcript section headers
document.querySelectorAll('ytd-transcript-section-header-renderer').forEach((el) => {
const title = el.querySelector('.shelf-header-layout-wiz__title')?.textContent?.trim();
if (title) chapters.push({ title });
});

// New UI: chapter view models
document.querySelectorAll('timeline-chapter-view-model').forEach((el) => {
const title = el.querySelector('.ytwTimelineChapterViewModelTitle')?.textContent?.trim();
const timestamp = el
.querySelector('.ytwTimelineChapterViewModelTimestamp')
?.textContent?.trim();
if (title) chapters.push({ title, timestamp });
});

return chapters;
}

export function formatChaptersSection(chapters) {
if (!chapters.length) return '';
const lines = chapters.map((ch) => {
return ch.timestamp ? `- ${ch.title} (${ch.timestamp})` : `- ${ch.title}`;
});
return `## Chapters\n${lines.join('\n')}`;
}

export function extractTranscriptText(includeTimestamps = true, includeChapters = true) {
let transcript = '';
const allElements = Array.from(
document.querySelectorAll(
'ytd-transcript-segment-renderer, ytd-transcript-section-header-renderer, transcript-segment-view-model'
'ytd-transcript-segment-renderer, ytd-transcript-section-header-renderer, transcript-segment-view-model, timeline-chapter-view-model'
)
);
allElements.forEach((element) => {
if (element.tagName === 'YTD-TRANSCRIPT-SECTION-HEADER-RENDERER') {
const tag = element.tagName;
if (includeChapters && tag === 'YTD-TRANSCRIPT-SECTION-HEADER-RENDERER') {
const headerText = element
.querySelector('.shelf-header-layout-wiz__title')
?.textContent?.trim();
if (headerText) {
transcript += `\n\n## ${headerText}\n`;
}
} else if (includeChapters && tag === 'TIMELINE-CHAPTER-VIEW-MODEL') {
const headerText = element
.querySelector('.ytwTimelineChapterViewModelTitle')
?.textContent?.trim();
if (headerText) {
transcript += `\n\n## ${headerText}\n`;
}
} else if (
element.tagName === 'YTD-TRANSCRIPT-SEGMENT-RENDERER' ||
element.tagName === 'TRANSCRIPT-SEGMENT-VIEW-MODEL'
tag === 'YTD-TRANSCRIPT-SEGMENT-RENDERER' ||
tag === 'TRANSCRIPT-SEGMENT-VIEW-MODEL'
) {
const timestamp = element
.querySelector('.segment-timestamp, .ytwTranscriptSegmentViewModelTimestamp')
Expand Down Expand Up @@ -182,6 +229,7 @@ export async function copyYouTubeTranscript(settings = null) {
chrome.storage.sync.get(
{
includeTimestamps: true,
includeChapters: true,
addTitleToTranscript: true,
addChannelToTranscript: true,
addUrlToTranscript: true,
Expand Down
6 changes: 6 additions & 0 deletions extension/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,12 @@ <h2>YouTube Settings</h2>
description="Add time markers to transcript lines"
checked
></setting-toggle>
<setting-toggle
setting-id="includeChapters"
label="Include Chapters"
description="Include chapter headers and the chapters summary section"
checked
></setting-toggle>
<setting-toggle
setting-id="addTitleToTranscript"
label="Include Video Title"
Expand Down
1 change: 1 addition & 0 deletions extension/options/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { showStatus } from './ui.js';
const SETTING_ELEMENTS = {
// YouTube settings
includeTimestamps: { id: 'includeTimestamps', type: 'checkbox' },
includeChapters: { id: 'includeChapters', type: 'checkbox' },
addTitleToTranscript: { id: 'addTitleToTranscript', type: 'checkbox' },
addChannelToTranscript: { id: 'addChannelToTranscript', type: 'checkbox' },
addUrlToTranscript: { id: 'addUrlToTranscript', type: 'checkbox' },
Expand Down
1 change: 1 addition & 0 deletions extension/popup/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
const SETTING_ELEMENTS = {
// YouTube settings
includeTimestamps: { id: 'includeTimestamps', type: 'checkbox' },
includeChapters: { id: 'includeChapters', type: 'checkbox' },
addTitleToTranscript: { id: 'addTitleToTranscript', type: 'checkbox' },
addChannelToTranscript: { id: 'addChannelToTranscript', type: 'checkbox' },
addUrlToTranscript: { id: 'addUrlToTranscript', type: 'checkbox' },
Expand Down
2 changes: 2 additions & 0 deletions extension/shared/defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
export const DEFAULTS = {
// YouTube
includeTimestamps: true,
includeChapters: true,
addTitleToTranscript: true,
addChannelToTranscript: true,
addUrlToTranscript: true,
Expand Down Expand Up @@ -84,6 +85,7 @@ export const DEFAULTS = {
// Schema for validation during import
export const SETTING_SCHEMA = {
includeTimestamps: 'boolean',
includeChapters: 'boolean',
addTitleToTranscript: 'boolean',
addChannelToTranscript: 'boolean',
addUrlToTranscript: 'boolean',
Expand Down
144 changes: 143 additions & 1 deletion tests/unit/content/youtube.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { extractTranscriptText } from '../../../extension/content/youtube.js';
import {
extractTranscriptText,
extractChapters,
formatChaptersSection,
} from '../../../extension/content/youtube.js';

// Mock the dependencies of youtube.js since we're importing it
vi.mock('../../../extension/content/utils.js', () => ({
Expand Down Expand Up @@ -73,6 +77,15 @@ describe('YouTube content script logic', () => {
expect(result).toContain('## Chapter 1');
});

it('excludes chapter headers when includeChapters is false', () => {
const result = extractTranscriptText(true, false);

expect(result).toContain('[0:00]');
expect(result).toContain('[0:05]');
expect(result).toContain('[1:30]');
expect(result).not.toContain('## Chapter 1');
});

it('extracts transcript from the new YouTube transcript DOM', () => {
document.body.innerHTML = `
<macro-markers-panel-item-view-model>
Expand Down Expand Up @@ -122,5 +135,134 @@ describe('YouTube content script logic', () => {
expect(result).toBe('Text without timestamp');
expect(result).not.toContain('[');
});

it('extracts inline chapter headers from the new YouTube UI', () => {
document.body.innerHTML = `
<timeline-chapter-view-model class="ytwTimelineChapterViewModelHost">
<h3 class="ytwTimelineChapterViewModelTitle">Chapter 1: Introduction</h3>
</timeline-chapter-view-model>
<transcript-segment-view-model class="ytwTranscriptSegmentViewModelHost">
<div class="ytwTranscriptSegmentViewModelTimestamp">0:00</div>
<span class="yt-core-attributed-string">Welcome to the video</span>
</transcript-segment-view-model>
<timeline-chapter-view-model class="ytwTimelineChapterViewModelHost">
<h3 class="ytwTimelineChapterViewModelTitle">Chapter 2: Deep Dive</h3>
</timeline-chapter-view-model>
<transcript-segment-view-model class="ytwTranscriptSegmentViewModelHost">
<div class="ytwTranscriptSegmentViewModelTimestamp">5:30</div>
<span class="yt-core-attributed-string">Let us dive deeper</span>
</transcript-segment-view-model>
`;

const result = extractTranscriptText(true);
expect(result).toContain('## Chapter 1: Introduction');
expect(result).toContain('[0:00] Welcome to the video');
expect(result).toContain('## Chapter 2: Deep Dive');
expect(result).toContain('[5:30] Let us dive deeper');
});

it('skips new UI inline chapter headers when includeChapters is false', () => {
document.body.innerHTML = `
<timeline-chapter-view-model class="ytwTimelineChapterViewModelHost">
<h3 class="ytwTimelineChapterViewModelTitle">Chapter 1: Introduction</h3>
</timeline-chapter-view-model>
<transcript-segment-view-model class="ytwTranscriptSegmentViewModelHost">
<div class="ytwTranscriptSegmentViewModelTimestamp">0:00</div>
<span class="yt-core-attributed-string">Welcome to the video</span>
</transcript-segment-view-model>
`;

const result = extractTranscriptText(true, false);
expect(result).not.toContain('## Chapter 1: Introduction');
expect(result).toContain('[0:00] Welcome to the video');
});
});

describe('extractChapters', () => {
afterEach(() => {
document.body.innerHTML = '';
});

it('extracts chapters from old UI (ytd-transcript-section-header-renderer)', () => {
document.body.innerHTML = `
<ytd-transcript-section-header-renderer>
<div class="shelf-header-layout-wiz__title">Introduction</div>
</ytd-transcript-section-header-renderer>
<ytd-transcript-section-header-renderer>
<div class="shelf-header-layout-wiz__title">Main Topic</div>
</ytd-transcript-section-header-renderer>
`;

const chapters = extractChapters();
expect(chapters).toHaveLength(2);
expect(chapters[0].title).toBe('Introduction');
expect(chapters[1].title).toBe('Main Topic');
});

it('extracts chapters from new UI (timeline-chapter-view-model)', () => {
document.body.innerHTML = `
<timeline-chapter-view-model class="ytwTimelineChapterViewModelHost">
<h3 class="ytwTimelineChapterViewModelTitle">Chapter 1: Intro</h3>
</timeline-chapter-view-model>
<timeline-chapter-view-model class="ytwTimelineChapterViewModelHost">
<h3 class="ytwTimelineChapterViewModelTitle">Chapter 2: The Problem</h3>
</timeline-chapter-view-model>
`;

const chapters = extractChapters();
expect(chapters).toHaveLength(2);
expect(chapters[0].title).toBe('Chapter 1: Intro');
expect(chapters[1].title).toBe('Chapter 2: The Problem');
});

it('extracts chapters with timestamps from new UI', () => {
document.body.innerHTML = `
<timeline-chapter-view-model class="ytwTimelineChapterViewModelHost">
<div class="ytwTimelineChapterViewModelTimestamp">0:00</div>
<h3 class="ytwTimelineChapterViewModelTitle">Chapter 1: Start</h3>
</timeline-chapter-view-model>
<timeline-chapter-view-model class="ytwTimelineChapterViewModelHost">
<div class="ytwTimelineChapterViewModelTimestamp">12:34</div>
<h3 class="ytwTimelineChapterViewModelTitle">Chapter 2: Middle</h3>
</timeline-chapter-view-model>
`;

const chapters = extractChapters();
expect(chapters).toHaveLength(2);
expect(chapters[0]).toEqual({ title: 'Chapter 1: Start', timestamp: '0:00' });
expect(chapters[1]).toEqual({ title: 'Chapter 2: Middle', timestamp: '12:34' });
});

it('returns empty array when no chapters exist', () => {
document.body.innerHTML = `
<transcript-segment-view-model>
<span class="yt-core-attributed-string">Just a segment</span>
</transcript-segment-view-model>
`;

const chapters = extractChapters();
expect(chapters).toHaveLength(0);
});
});

describe('formatChaptersSection', () => {
it('formats chapters without timestamps', () => {
const chapters = [{ title: 'Intro' }, { title: 'Main Topic' }];
const result = formatChaptersSection(chapters);
expect(result).toBe('## Chapters\n- Intro\n- Main Topic');
});

it('formats chapters with timestamps', () => {
const chapters = [
{ title: 'Intro', timestamp: '0:00' },
{ title: 'Deep Dive', timestamp: '5:30' },
];
const result = formatChaptersSection(chapters);
expect(result).toBe('## Chapters\n- Intro (0:00)\n- Deep Dive (5:30)');
});

it('returns empty string when no chapters', () => {
expect(formatChaptersSection([])).toBe('');
});
});
});
2 changes: 2 additions & 0 deletions tests/unit/popup/settings.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('popup.js - DEFAULTS', () => {
it('has all required settings', () => {
expect(DEFAULTS).toHaveProperty('globalEnabled');
expect(DEFAULTS).toHaveProperty('includeTimestamps');
expect(DEFAULTS).toHaveProperty('includeChapters');
expect(DEFAULTS).toHaveProperty('jumpToDomain');
expect(DEFAULTS).toHaveProperty('enableYouTubeIntegration');
expect(DEFAULTS).toHaveProperty('enableHackerNewsIntegration');
Expand All @@ -17,6 +18,7 @@ describe('popup.js - DEFAULTS', () => {
it('has proper default values', () => {
expect(DEFAULTS.globalEnabled).toBe(true);
expect(DEFAULTS.includeTimestamps).toBe(true);
expect(DEFAULTS.includeChapters).toBe(true);
expect(DEFAULTS.jumpToDomain).toBe(false);
expect(DEFAULTS.downloadInsteadOfCopy).toBe(false);
expect(DEFAULTS.enableUsageKpi).toBe(true);
Expand Down
1 change: 1 addition & 0 deletions tests/unit/shared/defaults.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ describe('shared/defaults', () => {

// YouTube settings
expect(DEFAULTS.includeTimestamps).toBe(true);
expect(DEFAULTS.includeChapters).toBe(true);
expect(DEFAULTS.addTitleToTranscript).toBe(true);
expect(DEFAULTS.addChannelToTranscript).toBe(true);
expect(DEFAULTS.addUrlToTranscript).toBe(true);
Expand Down
1 change: 1 addition & 0 deletions tests/unit/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe('utils.js', () => {
it('returns default settings when storage is empty', async () => {
const settings = await getSettings();
expect(settings.includeTimestamps).toBe(true);
expect(settings.includeChapters).toBe(true);
expect(settings.jumpToDomain).toBe(false);
});

Expand Down
Loading