diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a2204d8..928685ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Markdown Printer. +## v1.2.2 (2026-10-25) + +- Fixed the popup showing "Saved!" when nothing was saved — a failed save now says what went wrong (for example, on browser internal pages and extension stores, which browsers don't let extensions read) +- Fixed pages being left without saving on sites whose menus are links: while expanding collapsed sections, the capture no longer clicks links to other pages or buttons that submit a form +- A save started from the right-click menu that fails now shows a "!" on the toolbar icon; open the popup to see the reason + ## v1.2.1 (2026-10-25) - Fixed save statistics, rating preferences, and "Copy diagnostics" not being remembered — the extension now declares the `storage` permission it needs (data stays local in your browser) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index e93dfaf7..ead9539a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -27,6 +27,18 @@ "message": "Error - Try again", "description": "Save button label after a save failure" }, + "errorProtectedPage": { + "message": "This page can't be saved. Browsers don't let extensions read internal browser pages or extension stores.", + "description": "Shown in the popup when the current page cannot be scripted by extensions" + }, + "errorPageNavigated": { + "message": "The page changed while it was being saved, so nothing was saved. Reload the page and try again.", + "description": "Shown in the popup when the page navigated away during a save" + }, + "errorGeneric": { + "message": "Nothing was saved because of an unexpected error.", + "description": "Shown in the popup when a save failed for an unrecognized reason" + }, "ratingPromptTitle": { "message": "Enjoying Markdown Printer?", "description": "Initial rating prompt title" diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index 8714c3ca..31a6d2e7 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -27,6 +27,18 @@ "message": "Erreur - Réessayer", "description": "Save button label after a save failure" }, + "errorProtectedPage": { + "message": "Impossible d'enregistrer cette page. Les navigateurs interdisent aux extensions de lire les pages internes du navigateur et les boutiques d'extensions.", + "description": "Shown in the popup when the current page cannot be scripted by extensions" + }, + "errorPageNavigated": { + "message": "La page a changé pendant l'enregistrement, rien n'a donc été enregistré. Rechargez la page et réessayez.", + "description": "Shown in the popup when the page navigated away during a save" + }, + "errorGeneric": { + "message": "Rien n'a été enregistré à cause d'une erreur inattendue.", + "description": "Shown in the popup when a save failed for an unrecognized reason" + }, "ratingPromptTitle": { "message": "Vous aimez Markdown Printer ?", "description": "Initial rating prompt title" diff --git a/_locales/he/messages.json b/_locales/he/messages.json index 9622a711..94ebe90d 100644 --- a/_locales/he/messages.json +++ b/_locales/he/messages.json @@ -27,6 +27,18 @@ "message": "שגיאה - נסה שוב", "description": "Save button label after a save failure" }, + "errorProtectedPage": { + "message": "לא ניתן לשמור את הדף הזה. דפדפנים לא מאפשרים לתוספים לקרוא דפים פנימיים של הדפדפן או חנויות תוספים.", + "description": "Shown in the popup when the current page cannot be scripted by extensions" + }, + "errorPageNavigated": { + "message": "הדף השתנה בזמן השמירה, ולכן שום דבר לא נשמר. טען את הדף מחדש ונסה שוב.", + "description": "Shown in the popup when the page navigated away during a save" + }, + "errorGeneric": { + "message": "שום דבר לא נשמר בגלל שגיאה לא צפויה.", + "description": "Shown in the popup when a save failed for an unrecognized reason" + }, "ratingPromptTitle": { "message": "נהנים מ-Markdown Printer?", "description": "Initial rating prompt title" diff --git a/_locales/hi/messages.json b/_locales/hi/messages.json index 4254d274..855015f4 100644 --- a/_locales/hi/messages.json +++ b/_locales/hi/messages.json @@ -27,6 +27,18 @@ "message": "त्रुटि - पुनः प्रयास करें", "description": "Save button label after a save failure" }, + "errorProtectedPage": { + "message": "यह पेज सेव नहीं किया जा सकता। ब्राउज़र एक्सटेंशन को ब्राउज़र के आंतरिक पेज या एक्सटेंशन स्टोर पढ़ने नहीं देते।", + "description": "Shown in the popup when the current page cannot be scripted by extensions" + }, + "errorPageNavigated": { + "message": "सेव करते समय पेज बदल गया, इसलिए कुछ भी सेव नहीं हुआ। पेज रीलोड करके फिर से कोशिश करें।", + "description": "Shown in the popup when the page navigated away during a save" + }, + "errorGeneric": { + "message": "एक अनपेक्षित त्रुटि के कारण कुछ भी सेव नहीं हुआ।", + "description": "Shown in the popup when a save failed for an unrecognized reason" + }, "ratingPromptTitle": { "message": "क्या आपको Markdown Printer पसंद आ रहा है?", "description": "Initial rating prompt title" diff --git a/build.js b/build.js index 3d57883e..a055b4b8 100755 --- a/build.js +++ b/build.js @@ -21,6 +21,7 @@ const sharedSourceFiles = [ 'log-buffer.js', 'logger.js', 'rating-policy.js', + 'expand-policy.js', 'popup.html', 'popup.js', 'turndown.js', diff --git a/eslint.config.js b/eslint.config.js index bf6910e8..afff6232 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -45,6 +45,7 @@ module.exports = [ mdpGetDiagnostics: 'readonly', mdpLogBuffer: 'readonly', mdpRatingPolicy: 'readonly', + mdpExpandPolicy: 'readonly', }, }, rules: { @@ -77,7 +78,8 @@ module.exports = [ }, }, { - // The UMD-lite wrappers in src/log-buffer.js and src/rating-policy.js + // The UMD-lite wrappers in src/log-buffer.js, src/rating-policy.js and + // src/expand-policy.js // reference `module` so they can also be required from Node tests. files: [ 'build.js', @@ -87,6 +89,7 @@ module.exports = [ 'jest.config.js', 'src/log-buffer.js', 'src/rating-policy.js', + 'src/expand-policy.js', ], languageOptions: { globals: { diff --git a/package.json b/package.json index 2827f2ec..0f4e9ba7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "markdown-printer", - "version": "1.2.1", + "version": "1.2.2", "description": "Save web pages as Markdown files with preserved formatting", "scripts": { "build": "node build.js", diff --git a/src/background.js b/src/background.js index 08859896..0ca94a82 100644 --- a/src/background.js +++ b/src/background.js @@ -54,10 +54,39 @@ async function bumpSaveCount() { } } +function saveError(code, message) { + const error = new Error(message); + error.code = code; + return error; +} + +// The context menu has no UI of its own, so a failed save from it marks the +// toolbar icon and leaves the error for the popup to show when opened. +async function reportContextMenuError(error) { + try { + await browserAPI.storage.local.set({ + mdpLastSaveError: { code: error.code || null, message: error.message, at: Date.now() }, + }); + await browserAPI.action.setBadgeBackgroundColor({ color: '#d32f2f' }); + await browserAPI.action.setBadgeText({ text: '!' }); + } catch (e) { + log.error('reportContextMenuError failed', e); + } +} + +async function clearSaveError() { + try { + await browserAPI.storage.local.remove('mdpLastSaveError'); + await browserAPI.action.setBadgeText({ text: '' }); + } catch (e) { + log.error('clearSaveError failed', e); + } +} + // Handle context menu click browserAPI.contextMenus.onClicked.addListener((info, tab) => { if (info.menuItemId === 'saveAsMarkdown') { - savePageAsMarkdown(tab.id); + savePageAsMarkdown(tab.id).catch(reportContextMenuError); } }); @@ -65,26 +94,31 @@ browserAPI.contextMenus.onClicked.addListener((info, tab) => { browserAPI.runtime.onMessage.addListener((request, sender, sendResponse) => { if (request.action === 'saveAsMarkdown') { browserAPI.tabs.query({ active: true, currentWindow: true }, async tabs => { - if (tabs[0]) { - try { - await savePageAsMarkdown(tabs[0].id); - sendResponse({ success: true }); - } catch (error) { - sendResponse({ success: false, error: error.message }); + try { + if (!tabs || !tabs[0]) { + throw saveError('no-tab', 'Could not find the current tab'); } + const outcome = await savePageAsMarkdown(tabs[0].id); + sendResponse({ success: true, cancelled: outcome.cancelled }); + } catch (error) { + sendResponse({ success: false, error: error.message, code: error.code || null }); } }); return true; // Keep the message channel open for async response } }); +// Resolves to { cancelled } once the download has started; rejects (with +// error.code set where the cause is known) when nothing was saved. async function savePageAsMarkdown(tabId) { try { - // Inject Turndown library and conversion script + await clearSaveError(); + + // Inject Turndown library and the expand-click policy used by the conversion script await browserAPI.scripting .executeScript({ target: { tabId: tabId }, - files: ['turndown.js'], + files: ['turndown.js', 'expand-policy.js'], }) .catch(error => { // Better error message for protected pages @@ -93,7 +127,8 @@ async function savePageAsMarkdown(tabId) { error.message.includes('Cannot access') || error.message.includes('extensions gallery') ) { - throw new Error( + throw saveError( + 'protected-page', 'Cannot save this page - extensions are blocked on browser internal pages and extension stores' ); } @@ -112,9 +147,18 @@ async function savePageAsMarkdown(tabId) { const result = results[0].result; - // If operation was cancelled, exit without showing save dialog - if (!result || result === null) { - return; + // The user pressed Abort on the progress overlay: no download, not an error + if (result && result.cancelled) { + return { cancelled: true }; + } + + // No result at all means the capture script died before finishing, + // which happens when the tab navigates to another page mid-capture + if (!result) { + throw saveError( + 'page-navigated', + 'The page changed while it was being saved (it may have navigated to another page)' + ); } const { markdown, title, url } = result; @@ -171,6 +215,7 @@ async function savePageAsMarkdown(tabId) { }); } await bumpSaveCount(); + return { cancelled: false }; } catch (error) { log.error('Error saving markdown:', error); throw error; @@ -407,6 +452,11 @@ async function extractAndConvertToMarkdown() { const capturedContent = []; const capturedHashes = new Set(); + // Never click links or submit buttons: navigating away ends the capture + const isSafeToExpand = el => + typeof mdpExpandPolicy !== 'undefined' && + mdpExpandPolicy.isSafeToExpand(el, window.location.href); + // First, try to expand any collapsed/hidden sections const expandCollapsedSections = () => { // Find and click on common expandable elements @@ -427,10 +477,11 @@ async function extractAndConvertToMarkdown() { try { if (el.tagName === 'DETAILS') { el.open = true; - } else if (el.click) { + expandedCount++; + } else if (el.click && isSafeToExpand(el)) { el.click(); + expandedCount++; } - expandedCount++; } catch (_e) { // Ignore errors } @@ -730,9 +781,9 @@ async function extractAndConvertToMarkdown() { // Scroll through the page first const scrollResult = await scrollToBottom(); - // If scrolling was cancelled, return null to indicate no download should occur + // If scrolling was cancelled, tell the background no download should occur if (scrollResult.cancelled) { - return null; + return { cancelled: true }; } // Combine all captured content sections diff --git a/src/expand-policy.js b/src/expand-policy.js new file mode 100644 index 00000000..d3eab06c --- /dev/null +++ b/src/expand-policy.js @@ -0,0 +1,60 @@ +// Decides whether the page capture may click an element to expand it. +// Injected into the page before extractAndConvertToMarkdown (which can't +// import anything, since it is serialized by scripting.executeScript). +// Clicking a link or a submit button navigates the tab away mid-capture, +// which kills the capture script and loses the save. No DOM globals used +// here, so it stays testable in Node. +(function (root, factory) { + const mod = factory(); + if (typeof module !== 'undefined' && module.exports) { + module.exports = mod; + } else { + root.mdpExpandPolicy = mod; + } +})(typeof self !== 'undefined' ? self : globalThis, function () { + function withoutHash(url) { + return url.split('#')[0]; + } + + function isSafeLink(link, pageHref) { + if (link.hasAttribute('download')) { + return false; + } + const target = (link.getAttribute('target') || '').toLowerCase(); + if (target && target !== '_self') { + return false; + } + let url; + try { + url = new URL(link.getAttribute('href') || '', pageHref); + } catch (_e) { + return false; + } + if (url.protocol === 'javascript:') { + return true; + } + return withoutHash(url.href) === withoutHash(new URL(pageHref).href); + } + + function submitsForm(el) { + const tag = el.tagName; + const type = (el.type || '').toLowerCase(); + const isSubmit = + (tag === 'BUTTON' && type === 'submit') || + (tag === 'INPUT' && (type === 'submit' || type === 'image')); + return isSubmit && !!(el.form || el.closest('form')); + } + + function isSafeToExpand(el, pageHref) { + if (!el) { + return false; + } + const link = el.closest('a[href]'); + if (link && !isSafeLink(link, pageHref)) { + return false; + } + return !submitsForm(el); + } + + return { isSafeToExpand }; +}); diff --git a/src/popup.html b/src/popup.html index 8a96145a..486d894b 100644 --- a/src/popup.html +++ b/src/popup.html @@ -60,6 +60,19 @@ background: #ccc; cursor: not-allowed; } + .save-error { + margin-top: 10px; + padding: 8px 10px; + background: #fdecea; + border: 1px solid #f5c2bd; + border-radius: 4px; + color: #b71c1c; + font-size: 12px; + line-height: 1.4; + } + .save-error[hidden] { + display: none; + } .info { margin-top: 15px; font-size: 12px; @@ -151,6 +164,7 @@