@@ -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^="/~"]');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^="c_"]');if(!comment)return;const timeEl=comment.querySelector('time');const parentSubtree=subtree.parentElement?.closest('.comments_subtree');const parentComment=parentSubtree?.querySelector(':scope > .comment[id^="c_"]');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="tab-buttons"><button class="tab-btn active" data-view="default">Default</button><button class="tab-btn" data-view="latest">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="default"]').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="#${c.parentId}">@${c.parentAuthor}</a>`;byline.appendChild(replySpan)}}const timeLink=commentEl.querySelector('a[href^="/c/"]');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^="/c/"]').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- <style>
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- </style>
288- <div class="tab-buttons">
289- <button class="tab-btn active" data-view="default">Default</button>
290- <button class="tab-btn" data-view="latest">Latest</button>
291- </div>
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 <a href="#${c.parentId}">@${c.parentAuthor}</a>`;
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