diff --git a/quartz/components/renderPage.tsx b/quartz/components/renderPage.tsx index f788404321c2f..c639fadbba093 100644 --- a/quartz/components/renderPage.tsx +++ b/quartz/components/renderPage.tsx @@ -410,6 +410,9 @@ export function renderPage( data-read-later-saved={readLater.saved} data-read-later-removed={readLater.removed} data-read-later-failed={readLater.failed} + data-read-later-export={readLater.exportList} + data-read-later-exported={readLater.exported} + data-read-later-export-failed={readLater.exportFailed} data-resume-reading-continue={resumeReading.continueFrom} data-resume-reading-dismiss={resumeReading.dismiss} data-resume-reading-region={resumeReading.region} diff --git a/quartz/components/scripts/readLater.test.ts b/quartz/components/scripts/readLater.test.ts index 9b90447cfe84b..172c784ecddd3 100644 --- a/quartz/components/scripts/readLater.test.ts +++ b/quartz/components/scripts/readLater.test.ts @@ -7,6 +7,7 @@ import { readLaterScript } from "./readLater" import { READ_LATER_LIMIT, parseReadLaterEntries, + readLaterEntriesToMarkdown, toggleReadLaterEntry, type ReadLaterEntry, } from "./readLaterStorage" @@ -25,6 +26,8 @@ test("read-later browser script handles navigation and in-place renders", () => assert.match(readLaterScript, /window\.addCleanup\(cleanupReadLater\)/) assert.match(readLaterScript, /event\.key !== null && event\.key !== READ_LATER_KEY/) assert.match(readLaterScript, /const current = parseReadLaterEntry\(/) + assert.match(readLaterScript, /navigator\.clipboard\.writeText\(markdown\)/) + assert.match(readLaterScript, /exportButton\.disabled = entries\.length === 0/) }) test("read-later labels use the central locale catalog", () => { @@ -32,6 +35,9 @@ test("read-later labels use the central locale catalog", () => { assert.equal(zhCn.components.readLater.trigger({ count: 2 }), "稍后读,已保存 2 篇") assert.equal(zhTw.components.readLater.trigger({ count: 2 }), "稍後讀,已儲存 2 篇") assert.equal(enUs.components.readLater.removeItem({ title: "Note" }), "Remove Note") + assert.equal(enUs.components.readLater.exportList, "Copy Markdown checklist") + assert.equal(zhCn.components.readLater.exported, "Markdown 清单已复制") + assert.equal(zhTw.components.readLater.exportFailed, "瀏覽器未能複製 Markdown 清單") }) describe("read-later storage", () => { @@ -127,4 +133,29 @@ describe("read-later storage", () => { false, ) }) + + test("exports an Obsidian-ready Markdown checklist in saved order", () => { + const entries: readonly ReadLaterEntry[] = [ + { path: "/notes/newest?q=one", title: " Newest\n note ", savedAt: 20 }, + { path: "/notes/road-map", title: String.raw`Road \ [Map]`, savedAt: 10 }, + ] + + const markdown = readLaterEntriesToMarkdown(entries, "https://garden.example/current") + + assert.equal( + markdown, + [ + "- [ ] [Newest note]()", + String.raw`- [ ] [Road \\ \[Map\]]()`, + ].join("\n"), + ) + }) + + test("returns no checklist for empty entries or a non-web base URL", () => { + const entries: readonly ReadLaterEntry[] = [{ path: "/note", title: "Note", savedAt: 10 }] + + assert.equal(readLaterEntriesToMarkdown([], "https://garden.example/"), "") + assert.equal(readLaterEntriesToMarkdown(entries, "not a URL"), "") + assert.equal(readLaterEntriesToMarkdown(entries, "file:///garden/index.html"), "") + }) }) diff --git a/quartz/components/scripts/readLater.ts b/quartz/components/scripts/readLater.ts index 8def5e5fecbaa..2dcb1821951a2 100644 --- a/quartz/components/scripts/readLater.ts +++ b/quartz/components/scripts/readLater.ts @@ -2,6 +2,7 @@ import { READ_LATER_LIMIT, parseReadLaterEntries, parseReadLaterEntry, + readLaterEntriesToMarkdown, toggleReadLaterEntry, } from "./readLaterStorage" @@ -9,6 +10,7 @@ export const readLaterScript = ` const READ_LATER_KEY = "dg3.readLater.v1" const parseReadLaterEntry = ${parseReadLaterEntry.toString()} const parseReadLaterEntries = ${parseReadLaterEntries.toString()} +const readLaterEntriesToMarkdown = ${readLaterEntriesToMarkdown.toString()} const toggleReadLaterEntry = ${toggleReadLaterEntry.toString()} const READ_LATER_LIMIT = ${READ_LATER_LIMIT} @@ -55,6 +57,9 @@ function getReadLaterLabels() { readLaterSaved: saved, readLaterRemoved: removed, readLaterFailed: failed, + readLaterExport: exportList, + readLaterExported: exported, + readLaterExportFailed: exportFailed, } = document.body.dataset if ( !title || @@ -66,7 +71,10 @@ function getReadLaterLabels() { !empty || !saved || !removed || - !failed + !failed || + !exportList || + !exported || + !exportFailed ) { return undefined } @@ -96,6 +104,9 @@ function getReadLaterLabels() { saved, removed, failed, + exportList, + exported, + exportFailed, } } @@ -155,10 +166,14 @@ function initializeReadLater() { currentButton.append(createBookmarkIcon(), document.createElement("span")) const list = document.createElement("ul") list.className = "read-later-list" + const exportButton = document.createElement("button") + exportButton.type = "button" + exportButton.className = "read-later-export" + exportButton.textContent = labels.exportList const status = document.createElement("p") status.className = "read-later-status" status.setAttribute("aria-live", "polite") - panel.append(header, currentButton, list, status) + panel.append(header, currentButton, list, exportButton, status) root.append(trigger, panel) anchor.insertAdjacentElement("afterend", root) @@ -178,6 +193,7 @@ function initializeReadLater() { count.textContent = String(entries.length) trigger.title = labels.trigger(entries.length) trigger.setAttribute("aria-label", labels.trigger(entries.length)) + exportButton.disabled = entries.length === 0 list.replaceChildren() if (entries.length === 0) { const empty = document.createElement("li") @@ -231,6 +247,23 @@ function initializeReadLater() { status.textContent = wasSaved ? labels.removed : labels.saved render() }) + exportButton.addEventListener("click", async () => { + const markdown = readLaterEntriesToMarkdown(entries, location.href) + if (markdown.length === 0 || navigator.clipboard === undefined) { + status.textContent = labels.exportFailed + return + } + + exportButton.disabled = true + try { + await navigator.clipboard.writeText(markdown) + status.textContent = labels.exported + } catch { + status.textContent = labels.exportFailed + } finally { + exportButton.disabled = entries.length === 0 + } + }) const dismissOutside = (event) => { if (event.target instanceof Node && !root.contains(event.target)) closePanel(false) } diff --git a/quartz/components/scripts/readLaterStorage.ts b/quartz/components/scripts/readLaterStorage.ts index bdb46154d9b2c..5f7ca2f32b7e7 100644 --- a/quartz/components/scripts/readLaterStorage.ts +++ b/quartz/components/scripts/readLaterStorage.ts @@ -68,6 +68,35 @@ export function parseReadLaterEntries(raw: string | null): readonly ReadLaterEnt .slice(0, READ_LATER_LIMIT) } +export function readLaterEntriesToMarkdown( + entries: readonly ReadLaterEntry[], + baseUrl: string, +): string { + let base: URL + try { + base = new URL(baseUrl) + } catch { + return "" + } + if (base.protocol !== "http:" && base.protocol !== "https:") return "" + + const lines: string[] = [] + for (const candidate of entries) { + const entry = parseReadLaterEntry(candidate) + if (entry === undefined) continue + + const destination = new URL(entry.path, base) + if (destination.origin !== base.origin) continue + const label = entry.title + .replace(/\s+/gu, " ") + .trim() + .replace(/([\\[\]])/gu, "\\$1") + lines.push(`- [ ] [${label}](<${destination.href}>)`) + } + + return lines.join("\n") +} + export function toggleReadLaterEntry( entries: readonly ReadLaterEntry[], current: ReadLaterEntry, diff --git a/quartz/components/styles/readLater.scss b/quartz/components/styles/readLater.scss index 9f311e477b199..257d56d14f043 100644 --- a/quartz/components/styles/readLater.scss +++ b/quartz/components/styles/readLater.scss @@ -11,7 +11,8 @@ .read-later-trigger, .read-later-close, .read-later-current, -.read-later-remove { +.read-later-remove, +.read-later-export { appearance: none; box-sizing: border-box; border: 1px solid color-mix(in srgb, var(--gray) 54%, transparent); @@ -40,6 +41,15 @@ outline: 2px solid var(--secondary); outline-offset: 2px; } + + &:disabled { + border-color: color-mix(in srgb, var(--gray) 36%, transparent); + background-color: var(--light); + color: var(--gray); + cursor: not-allowed; + opacity: 0.7; + transform: none; + } } .read-later-trigger { @@ -148,6 +158,17 @@ text-align: start; } +.read-later-export { + display: flex; + width: 100%; + min-height: 2.5rem; + margin-top: 0.75rem; + padding: 0.5rem 0.75rem; + align-items: center; + justify-content: center; + text-align: center; +} + .read-later-list { max-height: 14rem; margin: 0.75rem 0 0; @@ -192,7 +213,8 @@ .read-later-trigger, .read-later-close, .read-later-current, - .read-later-remove { + .read-later-remove, + .read-later-export { transition: none; &:active { diff --git a/quartz/i18n/locales/definition.ts b/quartz/i18n/locales/definition.ts index 2b7aa77496baf..40f137fefd65e 100644 --- a/quartz/i18n/locales/definition.ts +++ b/quartz/i18n/locales/definition.ts @@ -63,6 +63,9 @@ export interface Translation { saved: string removed: string failed: string + exportList: string + exported: string + exportFailed: string } resumeReading?: { continueFrom: string diff --git a/quartz/i18n/locales/en-US.ts b/quartz/i18n/locales/en-US.ts index 3802658e657a8..98277a5b905c5 100644 --- a/quartz/i18n/locales/en-US.ts +++ b/quartz/i18n/locales/en-US.ts @@ -60,6 +60,9 @@ export default { saved: "Saved for later", removed: "Removed from read later", failed: "The browser could not save this change", + exportList: "Copy Markdown checklist", + exported: "Markdown checklist copied", + exportFailed: "The browser could not copy the Markdown checklist", }, resumeReading: { continueFrom: "Continue from {percent}%", diff --git a/quartz/i18n/locales/zh-CN.ts b/quartz/i18n/locales/zh-CN.ts index 3bc179b18176a..2acc6d9bdc77c 100644 --- a/quartz/i18n/locales/zh-CN.ts +++ b/quartz/i18n/locales/zh-CN.ts @@ -60,6 +60,9 @@ export default { saved: "已加入稍后读", removed: "已从稍后读移除", failed: "浏览器未能保存更改", + exportList: "复制 Markdown 清单", + exported: "Markdown 清单已复制", + exportFailed: "浏览器未能复制 Markdown 清单", }, resumeReading: { continueFrom: "从 {percent}% 继续阅读", diff --git a/quartz/i18n/locales/zh-TW.ts b/quartz/i18n/locales/zh-TW.ts index 4b975fd27ff7f..0c2b174e0b2f8 100644 --- a/quartz/i18n/locales/zh-TW.ts +++ b/quartz/i18n/locales/zh-TW.ts @@ -57,6 +57,9 @@ export default { saved: "已加入稍後讀", removed: "已從稍後讀移除", failed: "瀏覽器未能儲存變更", + exportList: "複製 Markdown 清單", + exported: "Markdown 清單已複製", + exportFailed: "瀏覽器未能複製 Markdown 清單", }, resumeReading: { continueFrom: "從 {percent}% 繼續閱讀",