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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ All notable changes to this project are documented here. The format is based on

## [Unreleased]

## [1.6.5] - 2026-10-05

### Fixed

- "Create AI link" on an X Article exports the Article again. When X mounted the article's post
before its reader, the button landed on that post and published a "thread" of the article's
images and the author's own replies, with no article text. The Article button now replaces it,
and any export aimed at the Article's own post runs as an Article.
- Article capsules take their title from the Article title, not the first heading in the body,
and include the cover image. X moved both outside the reader body.

## [1.6.4] - 2026-10-03

### Changed
Expand Down
2 changes: 1 addition & 1 deletion extension-src/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "SourceCapsule",
"version": "1.6.4",
"version": "1.6.5",
"description": "Save complete X threads, Articles, and posts as offline archives, agent-ready Markdown, or expiring share links.",
"permissions": ["activeTab", "storage", "nativeMessaging", "alarms", "tabs"],
"host_permissions": [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sourcecapsule",
"version": "1.6.4",
"version": "1.6.5",
"private": false,
"description": "Export an X (Twitter) Article or post to a single self-contained, fully-offline HTML file with all media and quoted tweets embedded.",
"keywords": [
Expand Down
2 changes: 1 addition & 1 deletion scripts/accept-extension.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ async function main() {
exts.includes(PINNED_ID),
exts.replace(/\n/g, ' | ')
);
check('extension reports v1.6.4 Enabled', /v1\.6\.4 .*Enabled|Enabled .*v1\.6\.4/.test(exts));
check('extension reports v1.6.5 Enabled', /v1\.6\.5 .*Enabled|Enabled .*v1\.6\.5/.test(exts));

const pages0 = await listPages();
const mainId = Number(pages0.find((p) => !p.isSw).id);
Expand Down
56 changes: 47 additions & 9 deletions sourcecapsule.user.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// ==UserScript==
// @name SourceCapsule - Save X/Twitter Threads & Articles as Markdown for LLMs + Offline HTML
// @namespace https://github.com/wolfgang-aura/SourceCapsule
// @version 1.6.4
// @version 1.6.5
// @description One click saves an X (Twitter) thread, Article, or post as clean Markdown for LLM context (Claude, ChatGPT) plus a self-contained offline HTML archive - images, video, and quoted posts embedded, with honest completeness reporting. Local-first, with optional expiring AI readable links.
// @author wolfgang-aura
// @license MIT
Expand Down Expand Up @@ -210,7 +210,7 @@
};

const APP = 'SourceCapsule';
const VERSION = '1.6.4';
const VERSION = '1.6.5';

// ===========================================================================
// Small utilities
Expand Down Expand Up @@ -2736,7 +2736,9 @@
}
const articleTweetEl = closestAny(root, CONFIG.selectors.tweet) || root;

const titleEl = pick(root, CONFIG.selectors.articleTitle, { quiet: true });
// X renders the title and cover beside the reader root, not inside it, and the body
// can open with its own h1. Look in the whole article tweet so the title testids win.
const titleEl = pick(articleTweetEl, CONFIG.selectors.articleTitle, { quiet: true });
const title = titleEl
? (titleEl.innerText || titleEl.textContent || '').trim()
: document.title.replace(/ \/ X.*$/, '');
Expand Down Expand Up @@ -2825,7 +2827,7 @@
}
});
}
findTweetImageEls(root).forEach((node) => addCandidate('image', node));
findTweetImageEls(articleTweetEl).forEach((node) => addCandidate('image', node));
pickAll(root, CONFIG.selectors.videoPlayer).forEach((node) => addCandidate('video', node));
quoteEls.forEach((node) => addCandidate('quote', node));
// Article body images do not belong to a tweet id. X can virtualize those
Expand Down Expand Up @@ -11240,17 +11242,40 @@ article[role="article"]:hover > .${CONFIG.postControlClass}:not(.xa-ctl-inline)
// those out from under the first and ship it with holes.
let exportInFlight = false;

// The focused status's own tweet, holding the Article reader. X can mount this tweet
// before the reader, so a per-post control may already sit on it.
function isFocusedArticleTweet(tweetEl) {
if (!tweetEl || tweetStatusId(tweetEl) !== currentStatusId()) return false;
return !!(
pick(tweetEl, CONFIG.selectors.articleTextRoot, { quiet: true }) ||
pick(tweetEl, CONFIG.selectors.articleRoot, { quiet: true })
);
}

// An export aimed at the focused Article tweet is an Article export. The post path
// reads tweetText only, so it built a "thread" of the article's images plus the
// author's own replies, with none of the article text (#41).
function resolveExportTarget(targetTweetEl, includeThread) {
if (isFocusedArticleTweet(targetTweetEl)) {
return { type: 'article', targetTweetEl: null, includeThread: false };
}
return { type: targetTweetEl ? 'post' : detectPageType(), targetTweetEl, includeThread };
}

async function runExport(
exportType,
{
targetTweetEl = null,
targetTweetEl: requestedTweetEl = null,
trigger = null,
includeThread = true,
includeThread: requestedThread = true,
automation = false,
expiryDays = 0,
} = {}
) {
const type = targetTweetEl ? 'post' : detectPageType();
const { type, targetTweetEl, includeThread } = resolveExportTarget(
requestedTweetEl,
requestedThread
);
if (!type) return;
const openFirstReason = timelineArticlePreviewReason(targetTweetEl, { includeThread });
if (openFirstReason) {
Expand Down Expand Up @@ -11700,6 +11725,7 @@ article[role="article"]:hover > .${CONFIG.postControlClass}:not(.xa-ctl-inline)
if (!tweetStatusId(tweetEl)) return; // only real posts (skip compose box / ads)
const mode = postControlCaptureMode(tweetEl, column);
const existing = tweetEl.querySelector(`.${CONFIG.postControlClass}`);
if (existing && existing.getAttribute('data-sourcecapsule-menu-mode') === 'article') return;
if (existing) {
// Cache key reflects WHICH menu array is actually rendered, not just isThread.
// A focused post now gets THREAD_EXPORT_TYPES even when auto-detection says
Expand Down Expand Up @@ -11796,9 +11822,19 @@ article[role="article"]:hover > .${CONFIG.postControlClass}:not(.xa-ctl-inline)
if (!CONFIG.perPostButtons) return;
const column = pick(document, CONFIG.selectors.primaryColumn, { quiet: true });
if (!column) return;
if (column.querySelector(`.${CONFIG.postControlClass}`)) return; // already injected
const caret = column.querySelector('[data-testid="caret"]'); // topmost = article header
const reader =
pick(document, CONFIG.selectors.articleTextRoot, { quiet: true }) ||
pick(document, CONFIG.selectors.articleRoot, { quiet: true });
const articleTweetEl = reader && closestAny(reader, CONFIG.selectors.tweet);
const host = articleTweetEl || column;
const existing = host.querySelector(`.${CONFIG.postControlClass}`);
if (existing && existing.getAttribute('data-sourcecapsule-menu-mode') === 'article') return;
// A post control on the article tweet was injected before X mounted the reader. Its
// menu offers post and thread exports, so swap it for the article control (#41).
if (existing && !articleTweetEl) return;
const caret = host.querySelector('[data-testid="caret"]'); // topmost = article header
if (!caret || !caret.parentElement) return;
if (existing) existing.remove();
const { wrap } = createExportControl({
triggerI18nKey: 'createAiLink',
triggerTitle: `Create an AI readable link for this article`,
Expand All @@ -11808,6 +11844,7 @@ article[role="article"]:hover > .${CONFIG.postControlClass}:not(.xa-ctl-inline)
});
wrap.classList.add('xa-ctl-inline');
wrap.setAttribute(CONFIG.postControlFlag, '1');
wrap.setAttribute('data-sourcecapsule-menu-mode', 'article');
caret.parentElement.insertBefore(wrap, caret);
}

Expand Down Expand Up @@ -12311,6 +12348,7 @@ article[role="article"]:hover > .${CONFIG.postControlClass}:not(.xa-ctl-inline)
writeReplyProbeResult,
runReplyProbe,
postControlCaptureMode,
resolveExportTarget,
authorFromNameBlock,
copyText,
waitForConversation,
Expand Down
80 changes: 80 additions & 0 deletions test/dom.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,86 @@ check('article-body image harvest survives virtualization without leaking quote
);
});

// Regression for #41. X no longer wraps an Article in `twitterArticleReadView`: the
// reader root is `twitterArticleRichTextView`, and the title and cover sit beside it in
// the article tweet. X can also mount that tweet before the reader, which put a per-post
// control on it; clicking that exported a "thread" of the article's images plus the
// author's own replies, with no article text at all.
const CURRENT_ARTICLE_URL = 'https://x.com/writer/status/2070000000000000001';
const currentArticleDom = new JSDOM(
`<!doctype html><html><body><div data-testid="primaryColumn">
<article data-testid="tweet" role="article" id="article-tweet">
<div data-testid="User-Name"><a href="/writer"><span>Writer</span></a><a href="/writer"><span>@writer</span></a></div>
<a href="/writer/status/2070000000000000001"><time datetime="2026-10-01T12:00:00Z">Oct 1</time></a>
<button data-testid="caret" type="button">...</button>
<div data-testid="twitter-article-title"><span>The Sunk Cost Cage</span></div>
<div data-testid="tweetPhoto"><img src="https://pbs.twimg.com/media/SunkCostCover?format=jpg&name=small" alt="Cover"></div>
<div data-testid="twitterArticleRichTextView">
<div data-testid="longformRichTextComponent">
<div data-block="true"><h1><span data-text="true">Intro to Crypto</span></h1></div>
<div data-block="true"><span data-text="true">The body paragraph must reach the capsule.</span></div>
<div data-block="true"><div data-testid="tweetPhoto"><img src="https://pbs.twimg.com/media/SunkCostChart?format=jpg&name=small" alt="Chart"></div></div>
</div>
</div>
</article>
<article data-testid="tweet" role="article" id="self-reply">
<div data-testid="User-Name"><a href="/writer"><span>Writer</span></a><a href="/writer"><span>@writer</span></a></div>
<div data-testid="tweetText"><span>Self reply must not join the article.</span></div>
<a href="/writer/status/2070000000000000002"><time datetime="2026-10-01T12:05:00Z">Oct 1</time></a>
<button data-testid="caret" type="button">...</button>
</article>
</div></body></html>`,
{ url: CURRENT_ARTICLE_URL }
);
global.window = currentArticleDom.window;
global.document = currentArticleDom.window.document;
global.Node = currentArticleDom.window.Node;
global.location = currentArticleDom.window.location;
global.localStorage = currentArticleDom.window.localStorage;
global.getComputedStyle = currentArticleDom.window.getComputedStyle;
global.window.confirm = () => false;
const currentArticleTweet = document.getElementById('article-tweet');
const currentSelfReply = document.getElementById('self-reply');

check('an article tweet that mounted before its reader gets the article control', () => {
const reader = document.querySelector('[data-testid="twitterArticleRichTextView"]');
reader.remove();
assert.equal(engine.detectPageType(), 'post');
engine.ensureButton();
assert.ok(currentArticleTweet.querySelector('.sourcecapsule-post-ctl'), 'race setup failed');
currentArticleTweet.appendChild(reader);
assert.equal(engine.detectPageType(), 'article');
engine.ensureButton();
const controls = currentArticleTweet.querySelectorAll('.sourcecapsule-post-ctl');
assert.equal(controls.length, 1);
assert.equal(controls[0].getAttribute('data-sourcecapsule-menu-mode'), 'article');
});

check('an export aimed at the focused article tweet runs as the article', () => {
assert.deepEqual(engine.resolveExportTarget(currentArticleTweet, true), {
type: 'article',
targetTweetEl: null,
includeThread: false,
});
const reply = engine.resolveExportTarget(currentSelfReply, true);
assert.equal(reply.type, 'post');
assert.equal(reply.targetTweetEl, currentSelfReply);
});

check('buildModelForArticle reads the title and cover beside the reader root', () => {
const model = engine.buildModelForArticle();
assert.equal(model.heading, 'The Sunk Cost Cage');
const images = model.blocks.filter((b) => b.kind === 'image');
assert.ok(
images[0] && images[0].url.includes('SunkCostCover'),
'cover image missing or not first'
);
assert.ok(images.some((image) => image.url.includes('SunkCostChart')));
const text = JSON.stringify(model.blocks);
assert.ok(text.includes('The body paragraph must reach the capsule.'));
assert.ok(!text.includes('Self reply must not join the article.'));
});

// ---------------------------------------------------------------------------
// Media harvest survives virtualization (the v0.2.5 fix). X recycles off-screen
// media out of the DOM, so we snapshot it during the scroll. Simulate that here:
Expand Down
Loading