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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 3 additions & 12 deletions Sources/RemindCore/ReminderURLNoteMirror.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<lineEnd])
if line.trimmingCharacters(in: .whitespaces) != candidateLine {
Expand Down
6 changes: 3 additions & 3 deletions Sources/remindctl/Commands/ExportCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,16 +98,16 @@ enum ExportCommand {
}
}

private static func csvField(_ value: String) -> 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
}
Expand Down
13 changes: 13 additions & 0 deletions Tests/RemindCoreTests/ReminderURLNoteMirrorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
Expand Down
12 changes: 12 additions & 0 deletions Tests/remindctlTests/ExportCommandTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down