Skip to content
Open
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
116 changes: 116 additions & 0 deletions MarkTo/Models/RTFToMarkdownConverter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Foundation
import AppKit

/// Class responsible for converting NSAttributedString (RTF) back to Markdown syntax.
class RTFToMarkdownConverter {

enum MarkdownStyle {
case bold
case italic
case inlineCode
}

/// Converts an NSAttributedString to a Markdown formatted string.
func convertToMarkdown(_ attributedString: NSAttributedString) -> String {
var markdownText = ""
let fullRange = NSRange(location: 0, length: attributedString.length)

attributedString.enumerateAttributes(in: fullRange, options: []) { attributes, range, stop in
let textRun = (attributedString.string as NSString).substring(with: range)
guard !textRun.isEmpty else { return }

// Check for list patterns or headers in the text string itself?
// (Wait, since it's just plain text we only need to extract traits from attributes)

var appliedStyles = [MarkdownStyle]()

if let font = attributes[.font] as? NSFont {
let traits = font.fontDescriptor.symbolicTraits

if traits.contains(.bold) {
appliedStyles.append(.bold)
}
if traits.contains(.italic) {
appliedStyles.append(.italic)
}
if font.fontDescriptor.fontAttributes[.family] as? String == "Menlo" ||
font.fontDescriptor.fontAttributes[.family] as? String == "Monaco" ||
font.fontDescriptor.fontAttributes[.family] as? String == "Courier" ||
traits.contains(.monoSpace) {
appliedStyles.append(.inlineCode)
}
}

// Note: If text run contains newlines, we need to wrap each line segment,
// or just wrap the whole thing if it doesn't cross block boundaries.
// For simplicity, we'll process the text and wrap it.

var processedText = escapeMarkdown(textRun)

// For lists and headings, if we detect them via paragraph style or something similar,
// but standard pasteboard might just preserve bullet characters instead of actual list attributes.
// So textRun might already contain "• " or tab indentations.

// Apply wrappers
if appliedStyles.contains(.inlineCode) {
processedText = "`\(processedText)`"
}

// bold and italic might surround spaces, which is bad markdown.
// e.g. "**hello **" should be "**hello** "
processedText = applyWrapping(text: processedText, prefix: "**", suffix: "**", if: appliedStyles.contains(.bold))
processedText = applyWrapping(text: processedText, prefix: "*", suffix: "*", if: appliedStyles.contains(.italic))

markdownText += processedText
}

return cleanUpMarkdown(markdownText)
}

private func applyWrapping(text: String, prefix: String, suffix: String, `if` condition: Bool) -> String {
guard condition else { return text }

// Handle whitespace at the edges to ensure valid markdown
// e.g. " hello " -> " **hello** "
var startSpaces = ""
var endSpaces = ""
var coreText = text

while coreText.hasPrefix(" ") || coreText.hasPrefix("\n") {
startSpaces += String(coreText.removeFirst())
}
while coreText.hasSuffix(" ") || coreText.hasSuffix("\n") {
endSpaces = String(coreText.removeLast()) + endSpaces
}

if coreText.isEmpty {
return text // if it was only spaces, don't wrap
}

return startSpaces + prefix + coreText + suffix + endSpaces
}

/// Escapes literal markdown characters so they aren't parsed as formatting later.
private func escapeMarkdown(_ text: String) -> String {
var escaped = text
let charactersToEscape = ["*", "_", "`", "[", "]", "#"]

for char in charactersToEscape {
// Only escape if the character is not already escaped.
// This is a naive replacement, it might double-escape if we aren't careful,
// but assuming raw RTF text, there are no existing escape characters acting as escapes.
escaped = escaped.replacingOccurrences(of: char, with: "\\\(char)")
}
return escaped
}

private func cleanUpMarkdown(_ text: String) -> String {
// Fix any overlapping boundaries or double spaces created by the wrapper logic
var cleaned = text
cleaned = cleaned.replacingOccurrences(of: "****", with: "")
cleaned = cleaned.replacingOccurrences(of: "** **", with: " ")
cleaned = cleaned.replacingOccurrences(of: "* *", with: " ")
cleaned = cleaned.replacingOccurrences(of: "``", with: "")
return cleaned
}
}
98 changes: 79 additions & 19 deletions MarkTo/ViewModels/MainViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,46 @@ class MainViewModel: ObservableObject {
@Published var isConverting: Bool = false
@Published var statusMessage: String = ""
@Published var isSuccess: Bool = false
@Published var hasRTFInClipboard: Bool = false

private let rtfToMarkdownConverter = RTFToMarkdownConverter()
private var clipboardTimer: Timer?
private var lastClipboardChangeCount: Int = 0

private let markdownConverter = MarkdownConverter()
private var statusTimer: Timer?
private var cancellables = Set<AnyCancellable>()

init() {
// Debounce text changes to avoid excessive processing
$markdownText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { [weak self] _ in
self?.clearStatus()
}
.store(in: &cancellables)
init() {
// Debounce text changes to avoid excessive processing
$markdownText
.debounce(for: .milliseconds(300), scheduler: RunLoop.main)
.sink { [weak self] _ in
self?.clearStatus()
}
.store(in: &cancellables)

startClipboardMonitoring()
}

private func startClipboardMonitoring() {
clipboardTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
self?.checkClipboardForRTF()
}
}

private func checkClipboardForRTF() {
let pasteboard = NSPasteboard.general
guard pasteboard.changeCount != lastClipboardChangeCount else { return }
lastClipboardChangeCount = pasteboard.changeCount

let hasRTF = pasteboard.availableType(from: [.rtf]) != nil
DispatchQueue.main.async {
withAnimation {
self?.hasRTFInClipboard = hasRTF
}
}
}

// MARK: - Public Methods

Expand Down Expand Up @@ -49,19 +75,52 @@ class MainViewModel: ObservableObject {
}
}

func loadClipboardContent() {
let pasteboard = NSPasteboard.general

// Try to get text from clipboard
guard let clipboardText = pasteboard.string(forType: .string) else { return }

// Only load if it looks like markdown and isn't too long
if clipboardText.count < 10000 && containsMarkdownSyntax(clipboardText) {
markdownText = clipboardText
showStatus("Loaded content from clipboard", isSuccess: true)
}
func loadClipboardContent() {
let pasteboard = NSPasteboard.general

if pasteboard.availableType(from: [.rtf]) != nil {
pasteRTFAsMarkdown()
return
}

guard let clipboardText = pasteboard.string(forType: .string) else { return }

if clipboardText.count < 10000 && containsMarkdownSyntax(clipboardText) {
markdownText = clipboardText
showStatus("Loaded content from clipboard", isSuccess: true)
}
}

func pasteRTFAsMarkdown() {
let pasteboard = NSPasteboard.general
guard let rtfData = pasteboard.data(forType: .rtf),
let attributedString = NSAttributedString(rtf: rtfData, documentAttributes: nil) else {
return
}

let markdownTextToPaste = rtfToMarkdownConverter.convertToMarkdown(attributedString)

if markdownText.isEmpty {
markdownText = markdownTextToPaste
} else {
markdownText += "\n" + markdownTextToPaste
}

showStatus("Converted Rich Text to Markdown", isSuccess: true)

withAnimation {
hasRTFInClipboard = false
}
}

func dismissRTFPrompt() {
withAnimation {
hasRTFInClipboard = false
}
}



func clearText() {
markdownText = ""
clearStatus()
Expand Down Expand Up @@ -135,6 +194,7 @@ class MainViewModel: ObservableObject {
}

deinit {
clipboardTimer?.invalidate()
statusTimer?.invalidate()
cancellables.removeAll()
}
Expand Down
26 changes: 26 additions & 0 deletions MarkTo/Views/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,32 @@ struct ContentView: View {
}
}

if viewModel.hasRTFInClipboard {
HStack {
Image(systemName: "wand.and.stars")
.foregroundColor(.blue)
Text("Rich text detected on clipboard.")
.font(.subheadline)
Spacer()
Button("Paste as Markdown") {
viewModel.pasteRTFAsMarkdown()
}
.buttonStyle(.borderedProminent)
.controlSize(.small)

Button(action: {
viewModel.dismissRTFPrompt()
}) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(10)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 10))
.transition(.move(edge: .top).combined(with: .opacity))
}

TextEditor(text: $viewModel.markdownText)
.font(.system(size: fontSize, design: .monospaced))
.scrollContentBackground(.hidden)
Expand Down
36 changes: 36 additions & 0 deletions MarkToTests/RTFToMarkdownConverterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import XCTest
@testable import MarkTo

final class RTFToMarkdownConverterTests: XCTestCase {

var converter: RTFToMarkdownConverter!

override func setUp() {
super.setUp()
converter = RTFToMarkdownConverter()
}

func testBasicText() {
let text = NSAttributedString(string: "Hello world")
XCTAssertEqual(converter.convertToMarkdown(text), "Hello world")
}

func testBoldText() {
let attrText = NSMutableAttributedString(string: "Hello world")
attrText.addAttribute(.font, value: NSFont.boldSystemFont(ofSize: 12), range: NSRange(location: 0, length: 5))
XCTAssertEqual(converter.convertToMarkdown(attrText), "**Hello** world")
}

func testItalicText() {
let fontManager = NSFontManager.shared
let italicFont = fontManager.convert(NSFont.systemFont(ofSize: 12), toHaveTrait: .italicFontMask)
let attrText = NSMutableAttributedString(string: "Hello world")
attrText.addAttribute(.font, value: italicFont, range: NSRange(location: 6, length: 5))
XCTAssertEqual(converter.convertToMarkdown(attrText), "Hello *world*")
}

func testEscaping() {
let text = NSAttributedString(string: "This has * and _ inside")
XCTAssertEqual(converter.convertToMarkdown(text), "This has \\* and \\_ inside")
}
}
23 changes: 6 additions & 17 deletions fix.rb
Original file line number Diff line number Diff line change
@@ -1,20 +1,9 @@
#!/usr/bin/env ruby
content = File.read("MarkTo/ViewModels/MainViewModel.swift")

file_path = 'MarkTo/Models/InlineProcessor.swift'
content = File.read(file_path)
# Fix missing properties
content.sub!(/ @Published var isSuccess: Bool = false\n/, " @Published var isSuccess: Bool = false\n @Published var hasRTFInClipboard: Bool = false\n\n private let rtfToMarkdownConverter = RTFToMarkdownConverter()\n private var clipboardTimer: Timer?\n private var lastClipboardChangeCount: Int = 0\n")

methods = [
'processStrikethrough',
'processBoldPattern',
'processItalicPattern',
'processCode',
'processAutoLinks',
'processBareURLs',
'processEmojis',
'processImages'
]
# Remove broken tail end of loadClipboardContent that wasn't cleaned up correctly
content.sub!(/ \/\/ Only load if it looks like markdown and isn't too long\n if clipboardText\.count < 10000 && containsMarkdownSyntax\(clipboardText\) \{\n markdownText = clipboardText\n showStatus\("Loaded content from clipboard", isSuccess: true\)\n \}\n \}/, "")

methods.each do |method_name|
# Replace 'let string = attributedString.string'
# with 'let nsString = attributedString.string as NSString'
end
File.write("MarkTo/ViewModels/MainViewModel.swift", content)