diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6c30e..762e29b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased - Reject impossible ISO dates, malformed offsets, and trailing date text; select explicit field order so day-first inputs cannot silently become another date while retaining unambiguous legacy spellings. +- Handle CRLF as a complete newline when removing managed URL notes and exporting CSV, preventing stale URL mirrors, split CSV records, and missed formula prefixes. ## 0.3.7 - 2026-09-13 diff --git a/Sources/RemindCore/ReminderURLNoteMirror.swift b/Sources/RemindCore/ReminderURLNoteMirror.swift index 3daad02..bf68c25 100644 --- a/Sources/RemindCore/ReminderURLNoteMirror.swift +++ b/Sources/RemindCore/ReminderURLNoteMirror.swift @@ -80,21 +80,12 @@ enum ReminderURLNoteMirror { var lineStart = text.startIndex while lineStart < text.endIndex { var lineEnd = lineStart - while lineEnd < text.endIndex, text[lineEnd] != "\n", text[lineEnd] != "\r" { + while lineEnd < text.endIndex, !text[lineEnd].isNewline { lineEnd = text.index(after: lineEnd) } - var nextLineStart = lineEnd - if nextLineStart < text.endIndex { - if text[nextLineStart] == "\r" { - nextLineStart = text.index(after: nextLineStart) - if nextLineStart < text.endIndex, text[nextLineStart] == "\n" { - nextLineStart = text.index(after: nextLineStart) - } - } else { - nextLineStart = text.index(after: nextLineStart) - } - } + // CRLF is one Swift Character, so consume the entire newline together. + let nextLineStart = lineEnd < text.endIndex ? text.index(after: lineEnd) : lineEnd let line = String(text[lineStart.. String { + static func csvField(_ value: String) -> String { let sanitized = neutralizeSpreadsheetFormula(value) - if sanitized.contains("\"") || sanitized.contains(",") || sanitized.contains("\n") || sanitized.contains("\r") { + if sanitized.contains("\"") || sanitized.contains(",") || sanitized.contains(where: \.isNewline) { return "\"\(sanitized.replacingOccurrences(of: "\"", with: "\"\""))\"" } return sanitized } static func neutralizeSpreadsheetFormula(_ value: String) -> String { - let trimmed = value.drop { $0 == " " || $0 == "\t" || $0 == "\r" || $0 == "\n" } + let trimmed = value.drop { $0 == " " || $0 == "\t" || $0.isNewline } guard let first = trimmed.first, ["=", "+", "-", "@"].contains(first) else { return value } diff --git a/Tests/RemindCoreTests/ReminderURLNoteMirrorTests.swift b/Tests/RemindCoreTests/ReminderURLNoteMirrorTests.swift index 7ba6c08..dfcfc74 100644 --- a/Tests/RemindCoreTests/ReminderURLNoteMirrorTests.swift +++ b/Tests/RemindCoreTests/ReminderURLNoteMirrorTests.swift @@ -126,6 +126,19 @@ struct ReminderURLNoteMirrorTests { #expect(ReminderURLNoteMirror.apply(notes: withMirror, showing: nil, replacing: url) == authoredNotes) } + @Test("URL edits remove embedded managed lines for each newline style", arguments: ["\n", "\r", "\r\n"]) + func editsEmbeddedManagedLines(newline: String) throws { + let oldURL = try #require(URL(string: "https://example.com/old")) + let newURL = try #require(URL(string: "https://example.com/new")) + let notes = "Before\(newline)remindctl URL (managed): https://example.com/old\(newline)After" + let authored = "Before\(newline)After" + + #expect(ReminderURLNoteMirror.apply(notes: notes, showing: nil, replacing: oldURL) == authored) + #expect( + ReminderURLNoteMirror.apply(notes: notes, showing: newURL, replacing: oldURL) + == "\(authored)\(newline)\(newline)remindctl URL (managed): https://example.com/new") + } + @Test("URL mirror preserves authored empty notes when clearing without a managed line") func preservesAuthoredEmptyNotesWhenClearingWithoutManagedLine() { let oldURL = URL(string: "https://example.com/old")! diff --git a/Tests/remindctlTests/ExportCommandTests.swift b/Tests/remindctlTests/ExportCommandTests.swift index 2f362bb..3f98216 100644 --- a/Tests/remindctlTests/ExportCommandTests.swift +++ b/Tests/remindctlTests/ExportCommandTests.swift @@ -11,6 +11,18 @@ struct ExportCommandTests { #expect(ExportCommand.neutralizeSpreadsheetFormula("normal") == "normal") } + @Test("CSV quotes multiline fields", arguments: ["\n", "\r", "\r\n"]) + func quotesMultilineFields(newline: String) { + let text = "Before\(newline)After" + #expect(ExportCommand.csvField(text) == "\"\(text)\"") + } + + @Test("CSV formula protection recognizes newline prefixes", arguments: ["\n", "\r", "\r\n", " \t\r\n"]) + func neutralizesFormulaAfterNewlines(prefix: String) { + #expect(ExportCommand.neutralizeSpreadsheetFormula("\(prefix)=1+1") == "'\(prefix)=1+1") + #expect(ExportCommand.neutralizeSpreadsheetFormula("\(prefix)normal") == "\(prefix)normal") + } + @Test("Export output honors runtime output precedence") func outputPrecedence() throws { #expect(try ExportCommand.outputMode(exportFormat: nil, runtimeFormat: .standard) == .json) diff --git a/docs/commands.md b/docs/commands.md index 98dec10..2a70851 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -123,6 +123,8 @@ remindctl completion zsh `open --list Work` keeps the historical open-reminders filter. Add `--app` to open that list in Reminders.app. +CSV export quotes multiline fields, including Windows-style CRLF text, and neutralizes formula prefixes after leading whitespace or newlines. Managed URL-note replacement and `--clear-url` also recognize CRLF lines while preserving authored text and its newline style. + ## Diagnostics ```bash