diff --git a/Sources/RemindCore/Models.swift b/Sources/RemindCore/Models.swift index 5f4fe90..0919beb 100644 --- a/Sources/RemindCore/Models.swift +++ b/Sources/RemindCore/Models.swift @@ -75,6 +75,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 571bc25..7ddf8af 100644 --- a/Sources/remindctl/Commands/AddCommand.swift +++ b/Sources/remindctl/Commands/AddCommand.swift @@ -18,6 +18,12 @@ enum AddCommand { .make(label: "list", names: [.short("l"), .long("list")], help: "List name", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Due date", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Notes", 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")], @@ -31,6 +37,8 @@ enum AddCommand { "remindctl add \"Buy milk\"", "remindctl add --title \"Call mom\" --list Personal --due tomorrow", "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") @@ -56,9 +64,14 @@ enum AddCommand { let notes = values.option("notes") let dueValue = values.option("due") let priorityValue = values.option("priority") + let tagValues = values.optionValues("tag") let dueDate = try dueValue.map(CommandHelpers.parseDueDate) 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() @@ -73,7 +86,7 @@ enum AddCommand { throw RemindCoreError.operationFailed("No default list found. Specify --list.") } - let draft = ReminderDraft(title: title, notes: notes, dueDate: dueDate, priority: priority) + let draft = ReminderDraft(title: titleWithTags, notes: notes, dueDate: dueDate, priority: priority) let reminder = try await store.createReminder(draft, listName: targetList) OutputRenderer.printReminder(reminder, format: runtime.outputFormat) } diff --git a/Sources/remindctl/Commands/EditCommand.swift b/Sources/remindctl/Commands/EditCommand.swift index 75a3c31..2b5754a 100644 --- a/Sources/remindctl/Commands/EditCommand.swift +++ b/Sources/remindctl/Commands/EditCommand.swift @@ -18,6 +18,18 @@ enum EditCommand { .make(label: "list", names: [.short("l"), .long("list")], help: "Move to list", parsing: .singleValue), .make(label: "due", names: [.short("d"), .long("due")], help: "Set due date", parsing: .singleValue), .make(label: "notes", names: [.short("n"), .long("notes")], help: "Set notes", 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")], @@ -27,6 +39,7 @@ enum EditCommand { ], flags: [ .make(label: "clearDue", names: [.long("clear-due")], help: "Clear due date"), + .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"), ] @@ -37,6 +50,8 @@ enum EditCommand { "remindctl edit 4A83 --due tomorrow", "remindctl edit 2 --priority high --notes \"Call before noon\"", "remindctl edit 3 --clear-due", + "remindctl edit 1 --tag urgent --remove-tag someday", + "remindctl edit 1 --clear-tags", ] ) { values, runtime in guard let input = values.argument(0) else { @@ -51,9 +66,25 @@ 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 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: Date?? if let dueValue = values.option("due") { @@ -78,7 +109,9 @@ enum EditCommand { } let isCompleted: Bool? = completeFlag ? true : (incompleteFlag ? false : nil) - if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil && isCompleted == nil { + if title == nil && listName == nil && notes == nil && dueUpdate == nil && priority == nil + && isCompleted == nil && !hasTagChange + { throw RemindCoreError.operationFailed("No changes specified") } diff --git a/Sources/remindctl/Commands/ShowCommand.swift b/Sources/remindctl/Commands/ShowCommand.swift index c484634..68fe879 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 ee85c61..a98ad61 100644 --- a/Sources/remindctl/OutputFormatting.swift +++ b/Sources/remindctl/OutputFormatting.swift @@ -20,6 +20,39 @@ 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 isCompleted: Bool + let completionDate: Date? + let priority: ReminderPriority + let dueDate: Date? + let listID: String + let listName: String + + init(reminder: ReminderItem) { + id = reminder.id + title = reminder.title + titleWithoutTags = reminder.titleWithoutTags + tags = reminder.tags + notes = reminder.notes + isCompleted = reminder.isCompleted + completionDate = reminder.completionDate + priority = reminder.priority + dueDate = reminder.dueDate + listID = reminder.listID + listName = reminder.listName + } +} + enum OutputRenderer { static func printReminders(_ reminders: [ReminderItem], format: OutputFormat) { switch format { @@ -28,7 +61,7 @@ enum OutputRenderer { case .plain: printRemindersPlain(reminders) case .json: - printJSON(reminders) + printJSON(reminders.map(ReminderOutput.init)) case .quiet: Swift.print(reminders.count) } @@ -51,16 +84,37 @@ enum OutputRenderer { switch format { case .standard: let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" - Swift.print("✓ \(reminder.title) [\(reminder.listName)] — \(due)") + Swift.print("✓ \(displayTitle(for: reminder)) [\(reminder.listName)] — \(due)") 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: @@ -98,7 +152,7 @@ enum OutputRenderer { let status = reminder.isCompleted ? "x" : " " let due = reminder.dueDate.map { DateParsing.formatDisplay($0) } ?? "no due date" let priority = reminder.priority == .none ? "" : " priority=\(reminder.priority.rawValue)" - Swift.print("[\(index + 1)] [\(status)] \(reminder.title) [\(reminder.listName)] — \(due)\(priority)") + Swift.print("[\(index + 1)] [\(status)] \(displayTitle(for: reminder)) [\(reminder.listName)] — \(due)\(priority)") } } @@ -121,6 +175,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")