Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,15 @@ function isInjectableUrl(url?: string): boolean {
}

export default defineBackground(() => {
browser.action.onClicked.addListener(() => {
copyCurrentPageAsMarkdown()
browser.action.onClicked.addListener(async (tab) => {
if (tab.id) {
try {
await browser.sidePanel.open({ tabId: tab.id })
} catch (error) {
console.error("Failed to open side panel:", error)
copyCurrentPageAsMarkdown()
}
}
})

browser.commands.onCommand.addListener((command) => {
Expand Down
99 changes: 85 additions & 14 deletions entrypoints/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,44 @@ import { createRoot } from "react-dom/client"
import Turndown from "turndown"
import { browser } from "wxt/browser"
import { getRoot, Noti, showNotification } from "@/lib/showNotification"
import { getOptions } from "@/lib/storage"
import { getOptions, type ExportFormat } from "@/lib/storage"
import { defaultTagsToRemove } from "@/lib/tagsToRemove"
import { convertSrtToText } from "@/lib/yt/convertSrtToText"
import { getVideoInfo } from "@/lib/yt/getVideoInfo"
import { getVideoSubtitle } from "@/lib/yt/getVideoSubtitle"

const tiktoken = new Tiktoken(o200k_base)

// Utility to copy markdown to clipboard, respond to sender and optionally show toast/confetti
// Utility to copy content to clipboard, respond to sender and optionally show toast/confetti
const copyAndNotify = async ({
markdown,
content,
exportFormat,
wrapInTripleBackticks,
showSuccessToast,
showConfetti,
sendResponse,
successMessagePrefix,
}: {
markdown: string
content: string
exportFormat: ExportFormat
wrapInTripleBackticks: boolean
showSuccessToast: boolean
showConfetti: boolean
sendResponse: (response: { success: boolean }) => void
successMessagePrefix: string
}) => {
if (wrapInTripleBackticks) {
markdown = `\`\`\`md\n${markdown}\n\`\`\``
let finalContent = content

if (exportFormat === "markdown" && wrapInTripleBackticks) {
finalContent = `\`\`\`md\n${finalContent}\n\`\`\``
}

try {
await navigator.clipboard.writeText(markdown)
await navigator.clipboard.writeText(finalContent)
} catch (error) {
// Fallback for when document is not focused (e.g., DevTools is open)
const textarea = document.createElement("textarea")
textarea.value = markdown
textarea.value = finalContent
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
Expand All @@ -50,7 +54,7 @@ const copyAndNotify = async ({

sendResponse({ success: true })

const tokens = tiktoken.encode(markdown)
const tokens = tiktoken.encode(finalContent)

if (showSuccessToast) {
showNotification(`${successMessagePrefix} (${tokens.length} tokens)`)
Expand All @@ -62,6 +66,54 @@ const copyAndNotify = async ({
}
}

// Function to convert markdown to plain text
const markdownToTxt = (markdown: string): string => {
let txt = markdown

// Remove code blocks
txt = txt.replace(/```[\s\S]*?```/g, "")

// Remove inline code
txt = txt.replace(/`[^`]+`/g, "")

// Remove headers
txt = txt.replace(/^#{1,6}\s+/gm, "")

// Remove bold and italic
txt = txt.replace(/\*\*\*([^*]+)\*\*\*/g, "$1")
txt = txt.replace(/\*\*([^*]+)\*\*/g, "$1")
txt = txt.replace(/\*([^*]+)\*/g, "$1")
txt = txt.replace(/___([^_]+)___/g, "$1")
txt = txt.replace(/__([^_]+)__/g, "$1")
txt = txt.replace(/_([^_]+)_/g, "$1")

// Remove links but keep text
txt = txt.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")

// Remove images
txt = txt.replace(/!\[([^\]]*)\]\([^)]+\)/g, "")

// Remove blockquotes
txt = txt.replace(/^>\s+/gm, "")

// Remove horizontal rules
txt = txt.replace(/^-{3,}$/gm, "")
txt = txt.replace(/^\*{3,}$/gm, "")
txt = txt.replace(/^_{3,}$/gm, "")

// Remove list markers
txt = txt.replace(/^[-*+]\s+/gm, "")
txt = txt.replace(/^\d+\.\s+/gm, "")

// Remove extra newlines (more than 2)
txt = txt.replace(/\n{3,}/g, "\n\n")

// Trim whitespace
txt = txt.trim()

return txt
}

export default defineContentScript({
matches: ["*://*/*"],
async main() {
Expand Down Expand Up @@ -89,6 +141,7 @@ export default defineContentScript({
showConfetti,
useDeffudle,
wrapInTripleBackticks,
exportFormat,
} = options

const html = msg.payload
Expand Down Expand Up @@ -140,13 +193,22 @@ export default defineContentScript({
.turndown(html)
}

let finalContent = markdown
let successMessagePrefix = "Copied as markdown"

if (exportFormat === "txt") {
finalContent = markdownToTxt(markdown)
successMessagePrefix = "Copied as text"
}

await copyAndNotify({
markdown,
content: finalContent,
exportFormat,
wrapInTripleBackticks,
showSuccessToast,
showConfetti,
sendResponse,
successMessagePrefix: "Copied as markdown",
successMessagePrefix,
})

return true
Expand All @@ -155,7 +217,7 @@ export default defineContentScript({
if (msg.type === "COPY_YOUTUBE_SUBTITLE") {
const options = await getOptions()

const { showSuccessToast, showConfetti, wrapInTripleBackticks } =
const { showSuccessToast, showConfetti, wrapInTripleBackticks, exportFormat } =
options

const videoId = msg.payload
Expand All @@ -177,13 +239,22 @@ export default defineContentScript({

markdown = `# ${title}\n\n\n${markdown}`

let finalContent = markdown
let successMessagePrefix = "Subtitle copied as markdown"

if (exportFormat === "txt") {
finalContent = markdownToTxt(markdown)
successMessagePrefix = "Subtitle copied as text"
}

await copyAndNotify({
markdown,
content: finalContent,
exportFormat,
wrapInTripleBackticks,
showSuccessToast,
showConfetti,
sendResponse,
successMessagePrefix: "Subtitle copied to clipboard",
successMessagePrefix,
})

return true
Expand Down
41 changes: 37 additions & 4 deletions entrypoints/options/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"
import { ToggleOption } from "@/components/ToggleOption"
import { getOptions, type OptionsState, saveOptions } from "@/lib/storage"
import { getOptions, type OptionsState, saveOptions, type ExportFormat } from "@/lib/storage"
import packageJson from "../../package.json"

export const App = () => {
Expand All @@ -17,15 +17,15 @@ export const App = () => {

const handleOptionChange = async (
key: keyof OptionsState,
value: boolean,
value: boolean | ExportFormat,
) => {
if (!options) return

let newOptions = { ...options, [key]: value }

if (key === "useReadability" && value) {
if (key === "useReadability" && value === true) {
newOptions = { ...newOptions, useDeffudle: false }
} else if (key === "useDeffudle" && value) {
} else if (key === "useDeffudle" && value === true) {
newOptions = { ...newOptions, useReadability: false }
}

Expand Down Expand Up @@ -98,6 +98,39 @@ export const App = () => {
}
infoLink="https://x.com/raycastapp/status/1691464764516343808"
/>

<div className="border-border border-t"></div>

<div className="py-3">
<div className="space-y-2">
<h3 className="font-medium text-sm leading-none">Export Format</h3>
<p className="text-pretty text-muted-foreground text-xs">
Choose the format for exporting content. Markdown preserves formatting, TXT is plain text.
</p>
</div>
<div className="mt-3 flex gap-2">
<button
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
options.exportFormat === "markdown"
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() => handleOptionChange("exportFormat", "markdown")}
>
Markdown
</button>
<button
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
options.exportFormat === "txt"
? "bg-primary text-primary-foreground"
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
}`}
onClick={() => handleOptionChange("exportFormat", "txt")}
>
TXT
</button>
</div>
</div>
</>
)}
</div>
Expand Down
Loading
Loading