diff --git a/CHANGELOG.md b/CHANGELOG.md index c2f116b..957ebab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extension-src/manifest.json b/extension-src/manifest.json index c12ded3..a224e43 100644 --- a/extension-src/manifest.json +++ b/extension-src/manifest.json @@ -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": [ diff --git a/package.json b/package.json index f76a2a4..26fc4a9 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/scripts/accept-extension.mjs b/scripts/accept-extension.mjs index e2d8745..29fdc83 100644 --- a/scripts/accept-extension.mjs +++ b/scripts/accept-extension.mjs @@ -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); diff --git a/sourcecapsule.user.js b/sourcecapsule.user.js index 9f72eb2..2e2f26b 100644 --- a/sourcecapsule.user.js +++ b/sourcecapsule.user.js @@ -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 @@ -210,7 +210,7 @@ }; const APP = 'SourceCapsule'; - const VERSION = '1.6.4'; + const VERSION = '1.6.5'; // =========================================================================== // Small utilities @@ -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.*$/, ''); @@ -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 @@ -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) { @@ -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 @@ -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`, @@ -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); } @@ -12311,6 +12348,7 @@ article[role="article"]:hover > .${CONFIG.postControlClass}:not(.xa-ctl-inline) writeReplyProbeResult, runReplyProbe, postControlCaptureMode, + resolveExportTarget, authorFromNameBlock, copyText, waitForConversation, diff --git a/test/dom.test.mjs b/test/dom.test.mjs index 62bce75..f1e054d 100644 --- a/test/dom.test.mjs +++ b/test/dom.test.mjs @@ -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( + `
+
+
Writer@writer
+ + +
The Sunk Cost Cage
+
Cover
+
+
+

Intro to Crypto

+
The body paragraph must reach the capsule.
+
Chart
+
+
+
+
+
Writer@writer
+
Self reply must not join the article.
+ + +
+
`, + { 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: