Skip to content

Commit 8c4c61b

Browse files
simonwclaudeclaude[bot]
authored
Add Copy Thread feature to lobsters bookmarklet, extract JS to separate file (#224)
* Add Copy Thread feature to lobsters bookmarklet, extract JS to separate file - Add "Copy Thread" button that exports the comment thread as numbered plain text (e.g., [1.2.3] author: text) to the clipboard, matching the format used by hacker-news-thread-export - Extract bookmarklet source code to lobsters-bookmarklet.js for easier editing - the HTML page now fetches and minifies it dynamically - Source code display in the details section is now loaded from the JS file https://claude.ai/code/session_01918zZQKJt6dazijwFTnMhN * Fix Copy Thread to find actual comments inside #story_comments The top-level ol.comments contains a comment form and a wrapper element, not actual comments. The real comment tree lives inside #story_comments > ol.comments. Tested against live lobste.rs thread. https://claude.ai/code/session_01918zZQKJt6dazijwFTnMhN * Fix Copy Thread bug: use extractComments() instead of broken DOM traversal The Copy Thread feature was failing because it tried to use a non-existent selector '#story_comments > ol.comments' and fell back to the main comments container which includes the comment form. This broke the DOM traversal. Fixed by: - Using the existing extractComments() function which correctly identifies comments - Building comment hierarchy from parent-child relationships in the extracted data - Removing dependency on the non-existent #story_comments selector 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
1 parent 0beead9 commit 8c4c61b

File tree

2 files changed

+281
-192
lines changed

2 files changed

+281
-192
lines changed

lobsters-bookmarklet.html

Lines changed: 30 additions & 192 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,15 @@ <h1>Lobsters Latest Comments</h1>
131131
<li><strong>Latest tab:</strong> Flat view sorted by time (newest first, oldest at bottom)</li>
132132
<li><strong>Reply links:</strong> Each reply shows "reply to @username" linking to the parent comment</li>
133133
<li><strong>Time link navigation:</strong> Clicking a comment's timestamp in Latest view switches back to Default and scrolls to that comment in the tree</li>
134+
<li><strong>Copy Thread:</strong> Export the entire comment thread as numbered plain text to the clipboard</li>
134135
</ul>
135136
</div>
136137

137138
<h2>Install the Bookmarklet</h2>
138139

139140
<p><strong>Drag this button to your bookmarks bar:</strong></p>
140141

141-
<a class="bookmarklet-link" href="javascript:(function(){if(document.querySelector('#comment-view-tabs'))return;const commentsLabel=document.querySelector('.comments_label');const commentsContainer=document.querySelector('ol.comments');if(!commentsLabel||!commentsContainer){alert('This bookmarklet only works on Lobste.rs comment pages');return}const originalCommentsHTML=commentsContainer.innerHTML;function getAuthor(element){const links=element.querySelectorAll('a[href^=&quot;/~&quot;]');for(const link of links){const text=link.textContent?.trim();if(text)return text}return null}function extractComments(){const comments=[];document.querySelectorAll('.comments_subtree').forEach(subtree=>{const comment=subtree.querySelector(':scope > .comment[id^=&quot;c_&quot;]');if(!comment)return;const timeEl=comment.querySelector('time');const parentSubtree=subtree.parentElement?.closest('.comments_subtree');const parentComment=parentSubtree?.querySelector(':scope > .comment[id^=&quot;c_&quot;]');comments.push({id:comment.id,element:comment.cloneNode(true),author:getAuthor(comment),timestamp:parseInt(timeEl?.getAttribute('data-at-unix')||'0'),parentId:parentComment?.id||null,parentAuthor:parentComment?getAuthor(parentComment):null})});return comments}const tabsContainer=document.createElement('div');tabsContainer.id='comment-view-tabs';tabsContainer.innerHTML=`<style>#comment-view-tabs{margin:10px 0}#comment-view-tabs .tab-buttons{display:flex;gap:0}#comment-view-tabs .tab-btn{padding:8px 16px;border:1px solid #ac0000;background:white;cursor:pointer;font-size:14px;color:#ac0000}#comment-view-tabs .tab-btn:first-child{border-radius:4px 0 0 4px}#comment-view-tabs .tab-btn:last-child{border-radius:0 4px 4px 0;border-left:none}#comment-view-tabs .tab-btn.active{background:#ac0000;color:white}#comment-view-tabs .tab-btn:hover:not(.active){background:#f0f0f0}.flat-comment{margin:0 0 15px 0!important;padding:10px!important;border-left:3px solid #ddd!important}.reply-to-link{font-size:12px;color:#666;margin-left:10px}.reply-to-link a{color:#ac0000;text-decoration:none}.reply-to-link a:hover{text-decoration:underline}</style><div class=&quot;tab-buttons&quot;><button class=&quot;tab-btn active&quot; data-view=&quot;default&quot;>Default</button><button class=&quot;tab-btn&quot; data-view=&quot;latest&quot;>Latest</button></div>`;const byline=commentsLabel.closest('.byline');byline.parentNode.insertBefore(tabsContainer,byline.nextSibling);function switchToDefault(scrollToId){document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));document.querySelector('.tab-btn[data-view=&quot;default&quot;]').classList.add('active');commentsContainer.innerHTML=originalCommentsHTML;if(scrollToId){setTimeout(()=>{const el=document.getElementById(scrollToId);if(el){el.scrollIntoView({behavior:'smooth',block:'center'});el.style.transition='background 0.3s';el.style.background='#ffffd0';setTimeout(()=>el.style.background='',2000)}},100)}}function buildFlatView(){const comments=extractComments();comments.sort((a,b)=>b.timestamp-a.timestamp);const flatContainer=document.createElement('div');comments.forEach(c=>{const wrapper=document.createElement('div');wrapper.className='flat-comment-wrapper';const commentEl=c.element;commentEl.classList.add('flat-comment');commentEl.style.marginLeft='0';if(c.parentId&&c.parentAuthor){const byline=commentEl.querySelector('.byline');if(byline){const replySpan=document.createElement('span');replySpan.className='reply-to-link';replySpan.innerHTML=` ↩ reply to <a href=&quot;#${c.parentId}&quot;>@${c.parentAuthor}</a>`;byline.appendChild(replySpan)}}const timeLink=commentEl.querySelector('a[href^=&quot;/c/&quot;]');if(timeLink){const commentId=c.id;timeLink.addEventListener('click',function(e){e.preventDefault();switchToDefault(commentId)})}wrapper.appendChild(commentEl);flatContainer.appendChild(wrapper)});return flatContainer}let flatViewCache=null;const tabButtons=tabsContainer.querySelectorAll('.tab-btn');tabButtons.forEach(btn=>{btn.addEventListener('click',()=>{tabButtons.forEach(b=>b.classList.remove('active'));btn.classList.add('active');const view=btn.dataset.view;if(view==='default'){commentsContainer.innerHTML=originalCommentsHTML}else if(view==='latest'){if(!flatViewCache){flatViewCache=buildFlatView()}commentsContainer.innerHTML='';commentsContainer.appendChild(flatViewCache.cloneNode(true));commentsContainer.querySelectorAll('a[href^=&quot;/c/&quot;]').forEach(link=>{const wrapper=link.closest('.flat-comment-wrapper');const commentEl=wrapper?.querySelector('.comment');const commentId=commentEl?.id;if(commentId){link.addEventListener('click',function(e){e.preventDefault();switchToDefault(commentId)})}})}})})})();">Lobsters Latest</a>
142+
<a class="bookmarklet-link" href="javascript:void(0)">Lobsters Latest</a>
142143

143144
<h2>Installation Instructions</h2>
144145

@@ -198,6 +199,7 @@ <h2>How It Works</h2>
198199
<li>When "Latest" is selected, displays comments in a flat list sorted by newest first</li>
199200
<li>Adds "reply to @username" links for comments that are replies</li>
200201
<li>Clicking the timestamp link (e.g., "14 hours ago") switches back to Default view and scrolls to that comment in the tree, with a brief yellow highlight</li>
202+
<li>The "Copy Thread" button exports the entire comment tree as numbered plain text (e.g., <code>[1.2.3] author: text</code>) and copies it to the clipboard</li>
201203
</ol>
202204

203205
<h2>Source Code</h2>
@@ -206,212 +208,48 @@ <h2>Source Code</h2>
206208

207209
<details>
208210
<summary style="cursor: pointer; color: #ac0000; font-weight: bold;">Click to expand source code</summary>
209-
<pre class="code-block" style="margin-top: 10px;">(function() {
210-
// Don't run twice
211-
if (document.querySelector('#comment-view-tabs')) return;
212-
213-
const commentsLabel = document.querySelector('.comments_label');
214-
const commentsContainer = document.querySelector('ol.comments');
215-
216-
if (!commentsLabel || !commentsContainer) {
217-
alert('This bookmarklet only works on Lobste.rs comment pages');
218-
return;
219-
}
220-
221-
// Store original HTML
222-
const originalCommentsHTML = commentsContainer.innerHTML;
223-
224-
// Helper to find author name
225-
function getAuthor(element) {
226-
const links = element.querySelectorAll('a[href^="/~"]');
227-
for (const link of links) {
228-
const text = link.textContent?.trim();
229-
if (text) return text;
230-
}
231-
return null;
232-
}
233-
234-
// Extract all comments with their data
235-
function extractComments() {
236-
const comments = [];
237-
document.querySelectorAll('.comments_subtree').forEach(subtree => {
238-
const comment = subtree.querySelector(':scope > .comment[id^="c_"]');
239-
if (!comment) return;
240-
241-
const timeEl = comment.querySelector('time');
242-
const parentSubtree = subtree.parentElement?.closest('.comments_subtree');
243-
const parentComment = parentSubtree?.querySelector(':scope > .comment[id^="c_"]');
244-
245-
comments.push({
246-
id: comment.id,
247-
element: comment.cloneNode(true),
248-
author: getAuthor(comment),
249-
timestamp: parseInt(timeEl?.getAttribute('data-at-unix') || '0'),
250-
parentId: parentComment?.id || null,
251-
parentAuthor: parentComment ? getAuthor(parentComment) : null
252-
});
253-
});
254-
return comments;
255-
}
256-
257-
// Create tabs
258-
const tabsContainer = document.createElement('div');
259-
tabsContainer.id = 'comment-view-tabs';
260-
tabsContainer.innerHTML = `
261-
&lt;style&gt;
262-
#comment-view-tabs { margin: 10px 0 }
263-
#comment-view-tabs .tab-buttons { display: flex; gap: 0 }
264-
#comment-view-tabs .tab-btn {
265-
padding: 8px 16px;
266-
border: 1px solid #ac0000;
267-
background: white;
268-
cursor: pointer;
269-
font-size: 14px;
270-
color: #ac0000;
271-
}
272-
#comment-view-tabs .tab-btn:first-child { border-radius: 4px 0 0 4px }
273-
#comment-view-tabs .tab-btn:last-child {
274-
border-radius: 0 4px 4px 0;
275-
border-left: none
276-
}
277-
#comment-view-tabs .tab-btn.active { background: #ac0000; color: white }
278-
#comment-view-tabs .tab-btn:hover:not(.active) { background: #f0f0f0 }
279-
.flat-comment {
280-
margin: 0 0 15px 0 !important;
281-
padding: 10px !important;
282-
border-left: 3px solid #ddd !important;
283-
}
284-
.reply-to-link { font-size: 12px; color: #666; margin-left: 10px }
285-
.reply-to-link a { color: #ac0000; text-decoration: none }
286-
.reply-to-link a:hover { text-decoration: underline }
287-
&lt;/style&gt;
288-
&lt;div class="tab-buttons"&gt;
289-
&lt;button class="tab-btn active" data-view="default"&gt;Default&lt;/button&gt;
290-
&lt;button class="tab-btn" data-view="latest"&gt;Latest&lt;/button&gt;
291-
&lt;/div&gt;
292-
`;
293-
294-
// Insert tabs
295-
const byline = commentsLabel.closest('.byline');
296-
byline.parentNode.insertBefore(tabsContainer, byline.nextSibling);
297-
298-
// Switch to default view and optionally scroll to a comment
299-
function switchToDefault(scrollToId) {
300-
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
301-
document.querySelector('.tab-btn[data-view="default"]').classList.add('active');
302-
commentsContainer.innerHTML = originalCommentsHTML;
303-
304-
if (scrollToId) {
305-
setTimeout(() => {
306-
const el = document.getElementById(scrollToId);
307-
if (el) {
308-
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
309-
el.style.transition = 'background 0.3s';
310-
el.style.background = '#ffffd0';
311-
setTimeout(() => el.style.background = '', 2000);
312-
}
313-
}, 100);
314-
}
315-
}
316-
317-
// Build flat view
318-
function buildFlatView() {
319-
const comments = extractComments();
320-
comments.sort((a, b) => b.timestamp - a.timestamp);
321-
322-
const flatContainer = document.createElement('div');
323-
324-
comments.forEach(c => {
325-
const wrapper = document.createElement('div');
326-
wrapper.className = 'flat-comment-wrapper';
327-
328-
const commentEl = c.element;
329-
commentEl.classList.add('flat-comment');
330-
commentEl.style.marginLeft = '0';
331-
332-
// Add reply-to link
333-
if (c.parentId && c.parentAuthor) {
334-
const byline = commentEl.querySelector('.byline');
335-
if (byline) {
336-
const replySpan = document.createElement('span');
337-
replySpan.className = 'reply-to-link';
338-
replySpan.innerHTML = ` ↩ reply to &lt;a href="#${c.parentId}"&gt;@${c.parentAuthor}&lt;/a&gt;`;
339-
byline.appendChild(replySpan);
340-
}
341-
}
342-
343-
// Add click handler for time link
344-
const timeLink = commentEl.querySelector('a[href^="/c/"]');
345-
if (timeLink) {
346-
const commentId = c.id;
347-
timeLink.addEventListener('click', function(e) {
348-
e.preventDefault();
349-
switchToDefault(commentId);
350-
});
351-
}
352-
353-
wrapper.appendChild(commentEl);
354-
flatContainer.appendChild(wrapper);
355-
});
356-
357-
return flatContainer;
358-
}
359-
360-
let flatViewCache = null;
361-
362-
// Tab switching
363-
const tabButtons = tabsContainer.querySelectorAll('.tab-btn');
364-
tabButtons.forEach(btn => {
365-
btn.addEventListener('click', () => {
366-
tabButtons.forEach(b => b.classList.remove('active'));
367-
btn.classList.add('active');
368-
369-
const view = btn.dataset.view;
370-
371-
if (view === 'default') {
372-
commentsContainer.innerHTML = originalCommentsHTML;
373-
} else if (view === 'latest') {
374-
if (!flatViewCache) {
375-
flatViewCache = buildFlatView();
376-
}
377-
commentsContainer.innerHTML = '';
378-
commentsContainer.appendChild(flatViewCache.cloneNode(true));
379-
380-
// Re-attach click handlers after cloning
381-
commentsContainer.querySelectorAll('a[href^="/c/"]').forEach(link => {
382-
const wrapper = link.closest('.flat-comment-wrapper');
383-
const commentEl = wrapper?.querySelector('.comment');
384-
const commentId = commentEl?.id;
385-
if (commentId) {
386-
link.addEventListener('click', function(e) {
387-
e.preventDefault();
388-
switchToDefault(commentId);
389-
});
390-
}
391-
});
392-
}
393-
});
394-
});
395-
})();</pre>
211+
<pre class="code-block" id="source-code" style="margin-top: 10px;">Loading...</pre>
396212
</details>
397213

398214
<footer>
399215
<p>Created with Claude. Works on <a href="https://lobste.rs">Lobste.rs</a> comment threads.</p>
400216
</footer>
401217

402218
<script>
403-
// Copy button extracts code from the bookmarklet link's href
404-
const copyBtn = document.getElementById('copy-bookmarklet-btn');
405219
const bookmarkletLink = document.querySelector('.bookmarklet-link');
220+
const sourceDisplay = document.getElementById('source-code');
221+
const copyBtn = document.getElementById('copy-bookmarklet-btn');
222+
223+
fetch('lobsters-bookmarklet.js')
224+
.then(r => r.text())
225+
.then(code => {
226+
// Display source code
227+
sourceDisplay.textContent = code;
228+
229+
// Minify for bookmarklet: strip full-line comments and collapse whitespace
230+
const minified = 'javascript:' + code
231+
.replace(/^\s*\/\/.*$/gm, '')
232+
.replace(/\n/g, ' ')
233+
.replace(/\s{2,}/g, ' ')
234+
.trim();
235+
236+
bookmarkletLink.href = minified;
237+
})
238+
.catch(err => {
239+
sourceDisplay.textContent = 'Failed to load source code: ' + err.message;
240+
});
406241

407242
copyBtn.onclick = () => {
408-
// Get the href attribute which contains the bookmarklet code
409243
const code = bookmarkletLink.getAttribute('href');
244+
if (!code || code === 'javascript:void(0)') {
245+
copyBtn.textContent = 'Still loading...';
246+
setTimeout(() => copyBtn.textContent = 'Copy Bookmarklet Code', 2000);
247+
return;
248+
}
410249
navigator.clipboard.writeText(code).then(() => {
411250
copyBtn.textContent = 'Copied!';
412251
setTimeout(() => copyBtn.textContent = 'Copy Bookmarklet Code', 2000);
413252
}).catch(() => {
414-
// Fallback for older browsers
415253
const textarea = document.createElement('textarea');
416254
textarea.value = code;
417255
document.body.appendChild(textarea);

0 commit comments

Comments
 (0)