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
14 changes: 14 additions & 0 deletions clippy.go
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,20 @@ func GetFiles() []string {
return clipboard.GetFiles()
}

// GetClipboardText returns text from clipboard, or error if clipboard is empty
func GetClipboardText() (string, error) {
text, ok := GetText()
if !ok {
return "", fmt.Errorf("clipboard is empty or contains no text")
}
return text, nil
}

// CopyRTF copies RTF data to clipboard
func CopyRTF(rtfData []byte) error {
return clipboard.CopyRTF(rtfData)
}

// isTextUTI checks if a UTI represents text content using macOS UTI system
func isTextUTI(uti string) bool {
// Use macOS UTI system to check if this UTI conforms to text types
Expand Down
59 changes: 59 additions & 0 deletions cmd/clippy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/neilberkman/clippy/internal/log"
"github.com/neilberkman/clippy/pkg/recent"
"github.com/neilberkman/clippy/pkg/spotlight"
"github.com/neilberkman/clippy/pkg/transform"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -252,6 +253,64 @@ Add to ~/Library/Application Support/Claude/claude_desktop_config.json:

rootCmd.AddCommand(mcpCmd)

// Add md2rtf subcommand
var md2rtfCmd = &cobra.Command{
Use: "md2rtf",
Short: "Convert markdown on clipboard to RTF",
Long: `Convert markdown text on clipboard to rich text (RTF) format.

This is useful for pasting formatted markdown into email clients (Apple Mail, Outlook)
without the extra line spacing issues that HTML paste causes.

The command reads markdown text from the clipboard, converts it to RTF with proper
formatting (bold, italic, headers, lists, etc.), and puts the RTF back on the clipboard.

Example workflow:
1. Copy markdown text to clipboard (from editor, terminal, etc.)
2. Run: clippy md2rtf
3. Paste into Apple Mail - formatting is preserved without extra spacing

The RTF output will preserve:
- Headers (#, ##, ###)
- Bold (**text**)
- Italic (*text*)
- Lists (-, *, numbered)
- Code blocks
- Links`,
Run: func(cmd *cobra.Command, args []string) {
// Read markdown from clipboard
mdText, err := clippy.GetClipboardText()
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading clipboard: %v\n", err)
os.Exit(1)
}

if mdText == "" {
fmt.Fprintln(os.Stderr, "Clipboard is empty")
os.Exit(1)
}

// Convert to RTF
rtfData, err := transform.MarkdownToRTF(mdText)
if err != nil {
fmt.Fprintf(os.Stderr, "Error converting markdown to RTF: %v\n", err)
os.Exit(1)
}

// Write RTF to clipboard
if err := clippy.CopyRTF(rtfData); err != nil {
fmt.Fprintf(os.Stderr, "Error writing RTF to clipboard: %v\n", err)
os.Exit(1)
}

if verbose {
fmt.Fprintln(os.Stderr, "Converted markdown to RTF on clipboard")
}
},
}

rootCmd.AddCommand(md2rtfCmd)

// Execute the command
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
Expand Down
85 changes: 85 additions & 0 deletions cmd/clippy/mcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/mark3labs/mcp-go/server"
"github.com/neilberkman/clippy"
"github.com/neilberkman/clippy/pkg/recent"
"github.com/neilberkman/clippy/pkg/transform"
)

// CopyArgs defines arguments for the copy tool
Expand Down Expand Up @@ -663,6 +664,90 @@ func StartServer() error {
}, nil
})

// Define md2rtf tool
md2rtfTool := mcp.NewTool(
"md2rtf",
mcp.WithDescription("Convert markdown text on clipboard to RTF format. Useful for pasting formatted markdown into email clients (Apple Mail, Outlook) without HTML's extra line spacing issues. Reads markdown from clipboard, converts to RTF with proper formatting (bold, italic, headers, lists, code blocks, links), and puts RTF back on clipboard. Perfect for drafting emails in markdown that paste cleanly."),
)

// Add md2rtf tool handler
s.AddTool(md2rtfTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Get markdown from clipboard
mdText, err := clippy.GetClipboardText()
if err != nil {
result := CopyResult{
Success: false,
Message: fmt.Sprintf("Failed to read clipboard: %v", err),
}
resultJSON, _ := json.Marshal(result)
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: string(resultJSON),
}},
}, nil
}

if mdText == "" {
result := CopyResult{
Success: false,
Message: "Clipboard is empty",
}
resultJSON, _ := json.Marshal(result)
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: string(resultJSON),
}},
}, nil
}

// Convert to RTF
rtfData, err := transform.MarkdownToRTF(mdText)
if err != nil {
result := CopyResult{
Success: false,
Message: fmt.Sprintf("Failed to convert markdown to RTF: %v", err),
}
resultJSON, _ := json.Marshal(result)
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: string(resultJSON),
}},
}, nil
}

// Write RTF to clipboard
if err := clippy.CopyRTF(rtfData); err != nil {
result := CopyResult{
Success: false,
Message: fmt.Sprintf("Failed to write RTF to clipboard: %v", err),
}
resultJSON, _ := json.Marshal(result)
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: string(resultJSON),
}},
}, nil
}

result := CopyResult{
Success: true,
Type: "rtf",
Message: "Converted markdown to RTF on clipboard",
}

resultJSON, _ := json.Marshal(result)
return &mcp.CallToolResult{
Content: []mcp.Content{mcp.TextContent{
Type: "text",
Text: string(resultJSON),
}},
}, nil
})

// Add prompts for common operations
s.AddPrompt(mcp.NewPrompt(
"copy-recent-download",
Expand Down
47 changes: 47 additions & 0 deletions pkg/clipboard/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,33 @@ int copyTextWithType(const char *text, const char *typeIdentifier) {
}
}

// Function to copy RTF data to the clipboard
int copyRTF(const void *rtfData, int length) {
@autoreleasepool {
[NSApplication sharedApplication]; // Initialize the app context
NSData *data = [NSData dataWithBytes:rtfData length:length];
NSPasteboard *pasteboard = [NSPasteboard generalPasteboard];

// Get the current changeCount before operation
NSInteger initialChangeCount = [pasteboard changeCount];

// Perform the write operation
[pasteboard clearContents];
BOOL success = [pasteboard setData:data forType:NSPasteboardTypeRTF];

if (!success) {
return -1; // Write operation failed to start
}

// Wait for pasteboard to complete
if (waitForPasteboardChange(pasteboard, initialChangeCount) != 0) {
return -2; // Timed out
}

return 0; // Success
}
}

// Get current clipboard file paths if any
char** getClipboardFiles(int *count) {
@autoreleasepool {
Expand Down Expand Up @@ -518,6 +545,26 @@ func CopyTextWithType(text string, typeIdentifier string) error {
}
}

// CopyRTF copies RTF data to clipboard
func CopyRTF(rtfData []byte) error {
if len(rtfData) == 0 {
return fmt.Errorf("empty RTF data")
}

result := C.copyRTF(unsafe.Pointer(&rtfData[0]), C.int(len(rtfData)))

switch result {
case 0:
return nil
case -1:
return fmt.Errorf("failed to write RTF to clipboard")
case -2:
return fmt.Errorf("clipboard operation timed out")
default:
return fmt.Errorf("unknown clipboard error: %d", result)
}
}

// Clear clears the clipboard
func Clear() error {
result := C.clearClipboard()
Expand Down
90 changes: 90 additions & 0 deletions pkg/transform/markdown_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//go:build darwin

package transform

/*
#cgo CFLAGS: -x objective-c
#cgo LDFLAGS: -framework Foundation -framework AppKit
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>

// convertMarkdownToRTF converts markdown text to RTF data
// Returns pointer to RTF bytes and length, or NULL/0 on error
typedef struct {
void* bytes;
size_t length;
} RTFResult;

RTFResult convertMarkdownToRTF(const char* markdown) {
RTFResult result = {NULL, 0};

@autoreleasepool {
NSString *markdownStr = [NSString stringWithUTF8String:markdown];
if (!markdownStr) {
return result;
}

NSError *error = nil;
NSAttributedString *attrStr = [[NSAttributedString alloc]
initWithMarkdown:[markdownStr dataUsingEncoding:NSUTF8StringEncoding]
options:nil
baseURL:nil
error:&error];

if (error || !attrStr) {
return result;
}

// Convert NSAttributedString to RTF data
NSRange range = NSMakeRange(0, [attrStr length]);
NSDictionary *attributes = @{NSDocumentTypeDocumentAttribute: NSRTFTextDocumentType};
NSData *rtfData = [attrStr dataFromRange:range
documentAttributes:attributes
error:&error];

if (error || !rtfData) {
return result;
}

// Copy the bytes so they survive autorelease pool
result.length = [rtfData length];
result.bytes = malloc(result.length);
if (result.bytes) {
memcpy(result.bytes, [rtfData bytes], result.length);
} else {
result.length = 0;
}
}

return result;
}

// freeRTFResult releases RTF result memory
void freeRTFResult(RTFResult result) {
if (result.bytes) {
free(result.bytes);
}
}
*/
import "C"
import (
"fmt"
"unsafe"
)

// MarkdownToRTF converts markdown text to RTF format
// Returns RTF data as bytes
func MarkdownToRTF(markdown string) ([]byte, error) {
cMarkdown := C.CString(markdown)
defer C.free(unsafe.Pointer(cMarkdown))

result := C.convertMarkdownToRTF(cMarkdown)
if result.bytes == nil || result.length == 0 {
return nil, fmt.Errorf("failed to convert markdown to RTF")
}
defer C.freeRTFResult(result)

// Copy RTF data to Go bytes
rtfBytes := C.GoBytes(result.bytes, C.int(result.length))
return rtfBytes, nil
}
Loading