Skip to content
Merged
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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,21 @@ clipdrop --ocr --auto-name # Name a screenshot from its recognized text
- The positional filename is optional; a provided extension is kept, otherwise format detection picks it
- Falls back to a content-based slug when Apple Intelligence isn't available

### 8. ✍️ **Transform** (macOS 26.0+) - Writing Tools for the Terminal
### 8. 📋 **Rich Text Copy** - Paste Markdown into Confluence
Copied Markdown pastes as raw `# syntax` in rich editors. `--rich` converts it
to HTML and puts it back on the clipboard with both rich-text and plain-text
flavors, so it pastes *formatted* into Confluence, Google Docs, Gmail, or Slack:
```bash
# Copy some markdown, then:
clipdrop --rich # Convert → paste formatted anywhere
clipdrop -r -p # Preview the HTML first
clipdrop -r page # Also save it → page.html
```
- Headings, lists, tables, links, and code blocks survive the paste
- Plain-text targets (and "Paste and Match Style") still get your original markdown
- Code fence languages are mapped to what Confluence recognizes (`js` → `javascript`)

### 9. ✍️ **Transform** (macOS 26.0+) - Writing Tools for the Terminal
Reshape copied text on the way to a file, entirely on-device:
```bash
clipdrop email.txt --rewrite formal # restyle (concise/friendly/…)
Expand Down Expand Up @@ -227,6 +241,7 @@ clipdrop -f <filename> # Force overwrite
```bash
clipdrop -yt # YouTube transcript mode
clipdrop --audio # Force audio transcription
clipdrop --rich # Markdown → rich text, back to the clipboard
```

### Filters & Options
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ dependencies = [
"requests>=2.34.2",
"lxml>=6.1.1",
"html2text>=2025.4.15",
"markdown-it-py>=3.0.0",
]

[project.urls]
Expand Down
95 changes: 95 additions & 0 deletions src/clipdrop/clipboard.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Clipboard operations module for ClipDrop."""

import subprocess
import sys
import time
from typing import Optional, Dict, Any
import pyperclip
Expand Down Expand Up @@ -27,6 +29,10 @@
# Maximum content size (100MB)
MAX_CONTENT_SIZE = 100 * 1024 * 1024

# Maximum HTML size for rich text clipboard writes (10MB); hex encoding
# doubles the osascript payload, so keep this well under MAX_CONTENT_SIZE
MAX_RICH_CONTENT_SIZE = 10 * 1024 * 1024


def get_text() -> Optional[str]:
"""
Expand Down Expand Up @@ -275,6 +281,95 @@ def copy_to_clipboard(content: str) -> None:
raise ClipboardAccessError("Cannot copy to clipboard", original_error=e)


def _escape_applescript_string(text: str) -> str:
"""Escape text for embedding in a double-quoted AppleScript string."""
text = text.replace('\r\n', '\n')
text = text.replace('\\', '\\\\')
text = text.replace('"', '\\"')
text = text.replace('\n', '\\n')
text = text.replace('\r', '\\n')
text = text.replace('\t', '\\t')
return text


def build_rich_clipboard_script(html: str, plain_text: str) -> str:
"""
Build an AppleScript that sets both HTML and plain-text clipboard flavors.

The HTML is hex-encoded into a «data HTML…» literal (the inverse of the
read path in html_parser.get_html_from_clipboard), so it needs no
escaping. The text attribute supplies the plain-text flavor — without it
paste breaks entirely in apps like Slack and Firefox.

Args:
html: HTML fragment for the rich text flavor
plain_text: Plain-text fallback (typically the original markdown)

Returns:
AppleScript source string
"""
hex_html = html.encode('utf-8').hex().upper()
escaped_text = _escape_applescript_string(plain_text)
return (
f'set the clipboard to '
f'{{text:"{escaped_text}", «class HTML»:«data HTML{hex_html}»}}'
)


def copy_rich_text_to_clipboard(html: str, plain_text: str) -> None:
"""
Copy rich text (HTML) to the macOS clipboard with a plain-text fallback.

Sets both the public.html and plain-text pasteboard flavors so rich
editors (Confluence, Google Docs, Gmail, Slack) paste formatted content
while plain-text targets get the original source.

Args:
html: HTML fragment for the rich text flavor
plain_text: Plain-text fallback (typically the original markdown)

Raises:
ContentTooLargeError: If HTML exceeds MAX_RICH_CONTENT_SIZE
ClipboardAccessError: If not on macOS or osascript fails
"""
html_size = len(html.encode('utf-8'))
if html_size > MAX_RICH_CONTENT_SIZE:
raise ContentTooLargeError(html_size, MAX_RICH_CONTENT_SIZE)

if sys.platform != 'darwin':
raise ClipboardAccessError(
"Rich text clipboard copy requires macOS"
)

script = build_rich_clipboard_script(html, plain_text)
try:
# Script over stdin: hex-encoded HTML can exceed argv size limits
result = subprocess.run(
['osascript', '-'],
input=script.encode('utf-8'),
capture_output=True,
timeout=10
)
except subprocess.TimeoutExpired as e:
raise ClipboardAccessError(
"Timed out writing rich text to clipboard", original_error=e
)
except OSError as e:
raise ClipboardAccessError(
"Cannot run osascript to write rich text", original_error=e
)

if result.returncode != 0:
stderr = result.stderr.decode('utf-8', errors='ignore').strip()
raise ClipboardAccessError(
f"Failed to write rich text to clipboard: {stderr or 'osascript error'}"
)

# Update cache with the plain-text flavor
_clipboard_cache['content'] = plain_text
_clipboard_cache['timestamp'] = time.time()


# Image clipboard operations
_image_cache: Dict[str, Any] = {
'image': None,
Expand Down
112 changes: 111 additions & 1 deletion src/clipdrop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
from rich.prompt import Confirm

from clipdrop import __version__
from clipdrop import clipboard, detect, files, images, pdf
from clipdrop import clipboard, detect, files, images, pdf, richtext
from clipdrop.macos_ai import summarize_content, summarize_content_with_chunking
from clipdrop.error_helpers import display_error, show_success_message
from clipdrop.paranoid import (
Expand All @@ -26,6 +26,8 @@
print_binary_skip_notice,
)
from clipdrop.exceptions import (
ClipboardAccessError,
ContentTooLargeError,
YTDLPNotFoundError,
NoCaptionsError,
YouTubeError
Expand Down Expand Up @@ -1039,6 +1041,87 @@ def handle_youtube_transcript(
raise typer.Exit(1)


def handle_rich_copy(
filename: Optional[str],
scan: bool,
paranoid_mode: Optional[ParanoidMode],
preview: bool,
force: bool,
yes: bool,
) -> None:
"""Convert clipboard Markdown to rich text (HTML) and copy it back.

Sets both HTML and plain-text clipboard flavors so pasting into rich
editors (Confluence, Google Docs, Gmail, Slack) produces formatted
content while plain-text targets still get the original markdown.
Optionally saves the HTML to a file when a filename is given.
"""
content = clipboard.get_text()
if not content or not content.strip():
display_error('empty_clipboard')
raise typer.Exit(1)

if not detect.is_markdown(content):
console.print(
"[yellow]⚠️ Clipboard content doesn't look like Markdown — "
"converting anyway[/yellow]"
)

# Scan the markdown source before it goes anywhere
active_paranoid = paranoid_mode or (ParanoidMode.PROMPT if scan else None)
if active_paranoid:
content, _ = paranoid_gate(
content,
active_paranoid,
is_tty=sys.stdin.isatty(),
auto_yes=yes
)
if content is None:
console.print("[yellow]⚠️ Content not copied (paranoid mode)[/yellow]")
raise typer.Exit(0)

try:
html = richtext.markdown_to_rich_html(content)
except ValueError as e:
console.print(f"[red]❌ {str(e)}[/red]")
raise typer.Exit(1)

if preview:
console.print(Panel(
Syntax(html, "html", word_wrap=True),
title="Rich text (HTML) preview",
border_style="cyan"
))
if not yes and not Confirm.ask(
"\n[yellow]Copy rich text to clipboard?[/yellow]"
):
console.print("[yellow]Cancelled[/yellow]")
raise typer.Exit(0)

try:
clipboard.copy_rich_text_to_clipboard(html, plain_text=content)
except (ContentTooLargeError, ClipboardAccessError) as e:
console.print(f"[red]❌ {str(e)}[/red]")
raise typer.Exit(1)

if filename:
output_filename = filename if Path(filename).suffix else f"{filename}.html"
files.write_text(output_filename, html, force=force)
console.print(f"[green]💾 HTML saved to '{output_filename}'[/green]")

html_size = len(html.encode('utf-8'))
size_str = (
f"{html_size:,} bytes" if html_size < 1024 else f"{html_size/1024:.1f} KB"
)
console.print(Panel(
"[green]✅ Rich text copied to clipboard[/green]\n"
"Paste into Confluence, Google Docs, Gmail, or Slack for "
"formatted content.\n"
f"[dim]HTML: {size_str} | Plain-text fallback: original markdown[/dim]",
border_style="green"
))


def main(
filename: Optional[str] = typer.Argument(
None,
Expand Down Expand Up @@ -1170,6 +1253,15 @@ def main(
"Outputs: .srt (subtitles), .txt (plain text), or .md (markdown). "
"Requires macOS 26.0+ with Apple Intelligence"
),
rich: bool = typer.Option(
False,
"--rich",
"-r",
help="Convert clipboard Markdown to rich text (HTML) and copy it "
"back to the clipboard for pasting into Confluence, Google "
"Docs, Gmail, or Slack. Saves the HTML too when a filename "
"is given"
),
version: Optional[bool] = typer.Option(
None,
"--version",
Expand Down Expand Up @@ -1208,6 +1300,11 @@ def main(
clipdrop -yt --lang es # Spanish transcript
clipdrop -yt --chapters # Include chapter markers

[green]Rich Text:[/green]
clipdrop --rich # Markdown → rich text, back to clipboard
clipdrop -r -p # Preview HTML before copying
clipdrop -r page # Also save the HTML → page.html

[green]Mixed Content:[/green]
clipdrop document # Mixed text+image → document.pdf
clipdrop content --text-only # Forces text mode
Expand Down Expand Up @@ -1263,6 +1360,18 @@ def main(
summarize=summarize,
)

# Check if this is rich text mode (before audio auto-detect so stale
# clipboard audio can't hijack the command)
if rich:
return handle_rich_copy(
filename=filename,
scan=paranoid_flag,
paranoid_mode=paranoid_mode,
preview=preview,
force=force,
yes=yes,
)

# Check for audio in clipboard (with or without filename)
try:
from clipdrop.macos_ai import check_audio_in_clipboard
Expand All @@ -1288,6 +1397,7 @@ def main(
console.print(" clipdrop data.json # Save JSON")
console.print(" clipdrop --youtube # Download YouTube transcript")
console.print(" clipdrop --audio # Transcribe audio from clipboard")
console.print(" clipdrop --rich # Markdown → rich text clipboard")
console.print(" clipdrop -yt output.srt # YouTube with custom name")
console.print("\n[dim]Try 'clipdrop --help' for more options[/dim]")
raise typer.Exit(1)
Expand Down
Loading