diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 757db00..b02ab8e 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -154,6 +154,39 @@ public struct ReminderItem: Identifiable, Codable, Sendable, Equatable { self.listID = listID self.listName = listName } + + public var tags: [String] { + Self.extractTrailingTags(from: title).tags + } + + public var titleWithoutTags: String { + Self.extractTrailingTags(from: title).title + } + + private static let trailingTagPattern = try! NSRegularExpression( + pattern: "(?:^|\\s)#([A-Za-z0-9][A-Za-z0-9_-]*)$" + ) + + private static func extractTrailingTags(from rawTitle: String) -> (title: String, tags: [String]) { + var title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines) + var extracted: [String] = [] + + while !title.isEmpty { + let range = NSRange(title.startIndex.. [String] { + var tags: [String] = [] + var seen: Set = [] + + for raw in rawValues { + for candidate in raw.split(separator: ",", omittingEmptySubsequences: false) { + var tag = String(candidate).trimmingCharacters(in: .whitespacesAndNewlines) + if tag.hasPrefix("#") { + tag.removeFirst() + } + guard !tag.isEmpty else { + throw RemindCoreError.operationFailed("Tag cannot be empty") + } + guard tag.range(of: #"^[A-Za-z0-9][A-Za-z0-9_-]*$"#, options: .regularExpression) != nil else { + throw RemindCoreError.operationFailed("Invalid tag: \"\(tag)\"") + } + let key = tag.lowercased() + if seen.insert(key).inserted { + tags.append(tag) + } + } + } + + return tags + } + + static func parseTitleTags(_ rawTitle: String) -> (baseTitle: String, tags: [String]) { + let pattern = #"(?:^|\s)#([A-Za-z0-9][A-Za-z0-9_-]*)$"# + var title = rawTitle.trimmingCharacters(in: .whitespacesAndNewlines) + var extracted: [String] = [] + + while !title.isEmpty, + let range = title.range(of: pattern, options: .regularExpression) + { + let match = String(title[range]) + let tag = + match + .trimmingCharacters(in: .whitespacesAndNewlines) + .dropFirst() + extracted.append(String(tag)) + title.removeSubrange(range) + title = title.trimmingCharacters(in: .whitespacesAndNewlines) + } + + return (baseTitle: title, tags: extracted.reversed()) + } + + static func composeTitle(baseTitle: String, tags: [String]) -> String { + let normalized = tags.map { "#\($0)" }.joined(separator: " ") + guard !normalized.isEmpty else { return baseTitle } + guard !baseTitle.isEmpty else { return normalized } + return "\(baseTitle) \(normalized)" + } + + static func mergeTags(existing: [String], add: [String], remove: [String], clear: Bool) -> [String] { + var current = clear ? [] : existing + + if !remove.isEmpty { + let removeSet = Set(remove.map { $0.lowercased() }) + current.removeAll { removeSet.contains($0.lowercased()) } + } + + var seen = Set(current.map { $0.lowercased() }) + for tag in add { + let key = tag.lowercased() + if seen.insert(key).inserted { + current.append(tag) + } + } + + return current + } } diff --git a/Sources/remindctl/CommandRouter.swift b/Sources/remindctl/CommandRouter.swift index 7117ec2..4909a87 100644 --- a/Sources/remindctl/CommandRouter.swift +++ b/Sources/remindctl/CommandRouter.swift @@ -12,6 +12,7 @@ struct CommandRouter { self.version = CommandRouter.resolveVersion() self.specs = [ ShowCommand.spec, + TagsCommand.spec, ListCommand.spec, AddCommand.spec, EditCommand.spec, diff --git a/Sources/remindctl/Commands/AddCommand.swift b/Sources/remindctl/Commands/AddCommand.swift index b33a935..e615cfa 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -37,6 +37,12 @@ enum AddCommand { help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years", parsing: .singleValue ), + .make( + label: "tag", + names: [.long("tag")], + help: "Tag name (repeatable or comma-separated)", + parsing: .singleValue + ), .make( label: "priority", names: [.short("p"), .long("priority")], @@ -56,6 +62,8 @@ enum AddCommand { "remindctl add \"Check mailbox\" --location \"1 Apple Park Way, Cupertino, CA\"", "remindctl add \"Take vitamins\" --due tomorrow --repeat daily", "remindctl add \"Review docs\" --priority high", + "remindctl add \"Buy milk\" --tag shopping --tag urgent", + "remindctl add \"Buy milk\" --tag shopping,urgent", ] ) { values, runtime in let titleOption = values.option("title") @@ -85,6 +93,7 @@ enum AddCommand { let radiusValue = values.option("radius") let repeatValue = values.option("repeat") let priorityValue = values.option("priority") + let tagValues = values.optionValues("tag") let dueDate = try dueValue.map(CommandHelpers.parseDueDate) let alarmDate = try alarmValue.map(CommandHelpers.parseDueDate) @@ -95,6 +104,10 @@ enum AddCommand { ) let recurrenceRule = try repeatValue.map(CommandHelpers.parseRecurrence) let priority = try priorityValue.map(CommandHelpers.parsePriority) ?? .none + let tags = try CommandHelpers.parseTags(tagValues) + let parsedTitle = CommandHelpers.parseTitleTags(title) + let mergedTags = CommandHelpers.mergeTags(existing: parsedTitle.tags, add: tags, remove: [], clear: false) + let titleWithTags = CommandHelpers.composeTitle(baseTitle: parsedTitle.baseTitle, tags: mergedTags) let store = RemindersStore() try await store.requestAccess() @@ -110,7 +123,7 @@ enum AddCommand { } let draft = ReminderDraft( - title: title, + title: titleWithTags, notes: notes, dueDate: dueDate, alarmDate: alarmDate, diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift index a7a7992..a5c0296 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -25,6 +25,18 @@ enum EditCommand { help: "daily|weekly|biweekly|monthly|yearly|every N days/weeks/months/years", parsing: .singleValue ), + .make( + label: "tag", + names: [.long("tag")], + help: "Add tag (repeatable or comma-separated)", + parsing: .singleValue + ), + .make( + label: "removeTag", + names: [.long("remove-tag")], + help: "Remove tag (repeatable or comma-separated)", + parsing: .singleValue + ), .make( label: "priority", names: [.short("p"), .long("priority")], @@ -36,6 +48,7 @@ enum EditCommand { .make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"), .make(label: "clearAlarm", names: [.long("clear-alarm")], help: "Clear alarm"), .make(label: "noRepeat", names: [.long("no-repeat")], help: "Remove recurrence"), + .make(label: "clearTags", names: [.long("clear-tags")], help: "Remove all tags"), .make(label: "complete", names: [.long("complete")], help: "Mark completed"), .make(label: "incomplete", names: [.long("incomplete")], help: "Mark incomplete"), ] @@ -48,6 +61,8 @@ enum EditCommand { "remindctl edit 4A83 --repeat weekly", "remindctl edit 2 --priority high --notes \"Call before noon\"", "remindctl edit 3 --clear-due --clear-alarm --no-repeat", + "remindctl edit 1 --tag urgent --remove-tag someday", + "remindctl edit 1 --clear-tags", ] ) { values, runtime in guard let input = values.argument(0) else { @@ -62,11 +77,27 @@ enum EditCommand { throw RemindCoreError.reminderNotFound(input) } - let title = values.option("title") + var title = values.option("title") let listName = values.option("list") let notes = values.option("notes") let alarmValue = values.option("alarm") let repeatValue = values.option("repeat") + let addTags = try CommandHelpers.parseTags(values.optionValues("tag")) + let removeTags = try CommandHelpers.parseTags(values.optionValues("removeTag")) + let clearTags = values.flag("clearTags") + let hasTagChange = !addTags.isEmpty || !removeTags.isEmpty || clearTags + + if hasTagChange { + let sourceTitle = title ?? reminder.title + let parsed = CommandHelpers.parseTitleTags(sourceTitle) + let merged = CommandHelpers.mergeTags( + existing: parsed.tags, + add: addTags, + remove: removeTags, + clear: clearTags + ) + title = CommandHelpers.composeTitle(baseTitle: parsed.baseTitle, tags: merged) + } var dueUpdate: ParsedUserDate?? if let dueValue = values.option("due") { @@ -115,7 +146,7 @@ enum EditCommand { let hasChanges = title != nil || listName != nil || notes != nil || dueUpdate != nil || alarmUpdate != nil || priority != nil - || recurrenceUpdate != nil || isCompleted != nil + || recurrenceUpdate != nil || isCompleted != nil || hasTagChange if !hasChanges { throw RemindCoreError.operationFailed("No changes specified") } diff --git a/Sources/remindctl/Commands/ShowCommand.swift b/Sources/remindctl/Commands/ShowCommand.swift index 04c4033..db430b1 100644 --- a/Sources/remindctl/Commands/ShowCommand.swift +++ b/Sources/remindctl/Commands/ShowCommand.swift @@ -23,7 +23,13 @@ enum ShowCommand { names: [.short("l"), .long("list")], help: "Limit to a specific list", parsing: .singleValue - ) + ), + .make( + label: "tag", + names: [.long("tag")], + help: "Filter by tag (repeatable or comma-separated)", + parsing: .singleValue + ), ] ) ), @@ -33,10 +39,12 @@ enum ShowCommand { "remindctl show overdue", "remindctl show 2026-01-04", "remindctl show --list Work", + "remindctl show --tag shopping", ] ) { values, runtime in let listName = values.option("list") let filterToken = values.argument(0) + let tagFilters = try CommandHelpers.parseTags(values.optionValues("tag")).map { $0.lowercased() } let filter: ReminderFilter if let token = filterToken { @@ -51,7 +59,17 @@ enum ShowCommand { let store = RemindersStore() try await store.requestAccess() let reminders = try await store.reminders(in: listName) - let filtered = ReminderFiltering.apply(reminders, filter: filter) + let filteredByDate = ReminderFiltering.apply(reminders, filter: filter) + let filtered: [ReminderItem] + if tagFilters.isEmpty { + filtered = filteredByDate + } else { + let tagFilterSet = Set(tagFilters) + filtered = filteredByDate.filter { reminder in + let reminderTags = Set(reminder.tags.map { $0.lowercased() }) + return !tagFilterSet.isDisjoint(with: reminderTags) + } + } OutputRenderer.printReminders(filtered, format: runtime.outputFormat) } } diff --git a/Sources/remindctl/Commands/TagsCommand.swift b/Sources/remindctl/Commands/TagsCommand.swift new file mode 100644 index 0000000..90270dd --- /dev/null +++ b/Sources/remindctl/Commands/TagsCommand.swift @@ -0,0 +1,54 @@ +import Commander +import Foundation +import RemindCore + +enum TagsCommand { + static var spec: CommandSpec { + CommandSpec( + name: "tags", + abstract: "List tags or reminders for a tag", + discussion: "Without an argument, prints all tags with counts. With a tag, shows reminders that match it.", + signature: CommandSignatures.withRuntimeFlags( + CommandSignature( + arguments: [ + .make(label: "tag", help: "Tag name", isOptional: true) + ] + ) + ), + usageExamples: [ + "remindctl tags", + "remindctl tags shopping", + ] + ) { values, runtime in + let requestedTag = values.argument(0) + + let store = RemindersStore() + try await store.requestAccess() + let reminders = try await store.reminders(in: nil) + + if let requestedTag { + let parsed = try CommandHelpers.parseTags([requestedTag]) + guard let filterTag = parsed.first, parsed.count == 1 else { + throw RemindCoreError.operationFailed("Provide a single tag") + } + let key = filterTag.lowercased() + let matching = reminders.filter { reminder in + reminder.tags.contains { $0.lowercased() == key } + } + OutputRenderer.printReminders(matching, format: runtime.outputFormat) + return + } + + var byKey: [String: TagSummary] = [:] + for reminder in reminders { + for tag in reminder.tags { + let key = tag.lowercased() + let existing = byKey[key] + byKey[key] = TagSummary(tag: existing?.tag ?? tag, count: (existing?.count ?? 0) + 1) + } + } + + OutputRenderer.printTagSummaries(Array(byKey.values), format: runtime.outputFormat) + } + } +} diff --git a/Sources/remindctl/OutputFormatting.swift b/Sources/remindctl/OutputFormatting.swift index 6dac77d..a4e06b5 100644 --- a/Sources/remindctl/OutputFormatting.swift +++ b/Sources/remindctl/OutputFormatting.swift @@ -20,6 +20,53 @@ struct AuthorizationSummary: Codable, Sendable, Equatable { let authorized: Bool } +struct TagSummary: Codable, Sendable, Equatable { + let tag: String + let count: Int +} + +struct ReminderOutput: Codable, Sendable, Equatable { + let id: String + let title: String + let titleWithoutTags: String + let tags: [String] + let notes: String? + let url: URL? + let isCompleted: Bool + let completionDate: Date? + let creationDate: Date? + let lastModifiedDate: Date? + let priority: ReminderPriority + let dueDate: Date? + let dueDateIsAllDay: Bool + let alarmDate: Date? + let recurrenceRule: RecurrenceRule? + let locationTrigger: LocationTrigger? + let listID: String + let listName: String + + init(reminder: ReminderItem) { + id = reminder.id + title = reminder.title + titleWithoutTags = reminder.titleWithoutTags + tags = reminder.tags + notes = reminder.notes + url = reminder.url + isCompleted = reminder.isCompleted + completionDate = reminder.completionDate + creationDate = reminder.creationDate + lastModifiedDate = reminder.lastModifiedDate + priority = reminder.priority + dueDate = reminder.dueDate + dueDateIsAllDay = reminder.dueDateIsAllDay + alarmDate = reminder.alarmDate + recurrenceRule = reminder.recurrenceRule + locationTrigger = reminder.locationTrigger + listID = reminder.listID + listName = reminder.listName + } +} + enum OutputRenderer { static func printReminders(_ reminders: [ReminderItem], format: OutputFormat) { switch format { @@ -28,7 +75,7 @@ enum OutputRenderer { case .plain: printRemindersPlain(reminders) case .json: - printJSON(reminders) + printJSON(reminders.map(ReminderOutput.init)) case .quiet: Swift.print(reminders.count) } @@ -55,16 +102,37 @@ enum OutputRenderer { DateParsing.formatDisplay($0, isDateOnly: reminder.dueDateIsAllDay) } ?? "no due date" let recurrence = recurrenceSuffix(for: reminder) - Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)\(recurrence)") + Swift.print("✓ \(displayTitle(for: reminder)) [\(reminder.listName)] — \(due)\(recurrence)") case .plain: Swift.print(plainLine(for: reminder)) case .json: - printJSON(reminder) + printJSON(ReminderOutput(reminder: reminder)) case .quiet: break } } + static func printTagSummaries(_ summaries: [TagSummary], format: OutputFormat) { + switch format { + case .standard: + guard !summaries.isEmpty else { + Swift.print("No tags found") + return + } + for summary in summaries.sorted(by: { $0.tag.localizedCaseInsensitiveCompare($1.tag) == .orderedAscending }) { + Swift.print("#\(summary.tag)\t\(summary.count)") + } + case .plain: + for summary in summaries.sorted(by: { $0.tag.localizedCaseInsensitiveCompare($1.tag) == .orderedAscending }) { + Swift.print("\(summary.tag)\t\(summary.count)") + } + case .json: + printJSON(summaries) + case .quiet: + Swift.print(summaries.count) + } + } + static func printDeleteResult(_ count: Int, format: OutputFormat) { switch format { case .standard: @@ -107,7 +175,8 @@ enum OutputRenderer { let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)" let recurrence = recurrenceSuffix(for: reminder) Swift.print( - "[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)\(recurrence)") + "[\(index + 1)] [\(status)] \(displayTitle(for: reminder)) [\(reminder.listName)] — \(due)\(priority)\(recurrence)" + ) } } @@ -138,6 +207,17 @@ enum OutputRenderer { ].joined(separator: "\t") } + private static func displayTitle(for reminder: ReminderItem) -> String { + let tags = reminder.tags.map { "#\($0)" }.joined(separator: " ") + if tags.isEmpty { + return reminder.titleWithoutTags + } + if reminder.titleWithoutTags.isEmpty { + return tags + } + return "\(reminder.titleWithoutTags) \(tags)" + } + private static func printListsStandard(_ summaries: [ListSummary]) { guard !summaries.isEmpty else { Swift.print("No reminder lists found") diff --git a/Tests/RemindCoreTests/ReminderTagParsingTests.swift b/Tests/RemindCoreTests/ReminderTagParsingTests.swift new file mode 100644 index 0000000..a55ceff --- /dev/null +++ b/Tests/RemindCoreTests/ReminderTagParsingTests.swift @@ -0,0 +1,42 @@ +import Testing + +@testable import RemindCore + +@MainActor +struct ReminderTagParsingTests { + @Test("Trailing hashtags are exposed as tags") + func trailingHashtagsAreTags() { + let reminder = ReminderItem( + id: "abc", + title: "Buy milk #shopping #urgent", + notes: nil, + isCompleted: false, + completionDate: nil, + priority: .none, + dueDate: nil, + listID: "list", + listName: "Inbox" + ) + + #expect(reminder.titleWithoutTags == "Buy milk") + #expect(reminder.tags == ["shopping", "urgent"]) + } + + @Test("Only trailing hashtags are parsed as tags") + func onlyTrailingHashtagsAreTags() { + let reminder = ReminderItem( + id: "abc", + title: "Discuss #hash syntax with team #work", + notes: nil, + isCompleted: false, + completionDate: nil, + priority: .none, + dueDate: nil, + listID: "list", + listName: "Inbox" + ) + + #expect(reminder.titleWithoutTags == "Discuss #hash syntax with team") + #expect(reminder.tags == ["work"]) + } +} diff --git a/Tests/remindctlTests/TagCommandTests.swift b/Tests/remindctlTests/TagCommandTests.swift new file mode 100644 index 0000000..f401fcf --- /dev/null +++ b/Tests/remindctlTests/TagCommandTests.swift @@ -0,0 +1,43 @@ +import Testing + +@testable import remindctl + +@MainActor +struct TagCommandTests { + @Test("Tag helpers parse, merge, and compose tags") + func tagHelpers() throws { + let tags = try CommandHelpers.parseTags(["shopping,urgent", "#Work"]) + #expect(tags == ["shopping", "urgent", "Work"]) + + let parsed = CommandHelpers.parseTitleTags("Buy milk #shopping #urgent") + #expect(parsed.baseTitle == "Buy milk") + #expect(parsed.tags == ["shopping", "urgent"]) + + let merged = CommandHelpers.mergeTags( + existing: parsed.tags, + add: ["Work"], + remove: ["urgent"], + clear: false + ) + #expect(merged == ["shopping", "Work"]) + #expect(CommandHelpers.composeTitle(baseTitle: parsed.baseTitle, tags: merged) == "Buy milk #shopping #Work") + } + + @Test("Help includes tag options") + func helpIncludesTagOptions() { + let rootHelp = HelpPrinter.renderRoot( + version: "0.0.0", + rootName: "remindctl", + commands: [ShowCommand.spec, TagsCommand.spec, AddCommand.spec, EditCommand.spec] + ).joined(separator: "\n") + let addHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: AddCommand.spec).joined(separator: "\n") + let editHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: EditCommand.spec).joined(separator: "\n") + let showHelp = HelpPrinter.renderCommand(rootName: "remindctl", spec: ShowCommand.spec).joined(separator: "\n") + + #expect(rootHelp.contains("tags")) + #expect(addHelp.contains("--tag")) + #expect(editHelp.contains("--remove-tag")) + #expect(editHelp.contains("--clear-tags")) + #expect(showHelp.contains("--tag")) + } +}