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
33 changes: 33 additions & 0 deletions Sources/RemindCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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..<title.endIndex, in: title)
guard let match = trailingTagPattern.firstMatch(in: title, options: [], range: range),
let fullRange = Range(match.range(at: 0), in: title),
let tagRange = Range(match.range(at: 1), in: title)
else {
break
}

extracted.append(String(title[tagRange]))
title.removeSubrange(fullRange)
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
}

return (title: title, tags: extracted.reversed())
}
}

public struct ReminderDraft: Sendable {
Expand Down
73 changes: 73 additions & 0 deletions Sources/remindctl/CommandHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,77 @@ enum CommandHelpers {
"""
)
}

static func parseTags(_ rawValues: [String]) throws -> [String] {
var tags: [String] = []
var seen: Set<String> = []

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
}
}
1 change: 1 addition & 0 deletions Sources/remindctl/CommandRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ struct CommandRouter {
self.version = CommandRouter.resolveVersion()
self.specs = [
ShowCommand.spec,
TagsCommand.spec,
ListCommand.spec,
AddCommand.spec,
EditCommand.spec,
Expand Down
15 changes: 14 additions & 1 deletion Sources/remindctl/Commands/AddCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand All @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -110,7 +123,7 @@ enum AddCommand {
}

let draft = ReminderDraft(
title: title,
title: titleWithTags,
notes: notes,
dueDate: dueDate,
alarmDate: alarmDate,
Expand Down
35 changes: 33 additions & 2 deletions Sources/remindctl/Commands/EditCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand All @@ -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"),
]
Expand All @@ -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 {
Expand All @@ -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") {
Expand Down Expand Up @@ -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")
}
Expand Down
22 changes: 20 additions & 2 deletions Sources/remindctl/Commands/ShowCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
]
)
),
Expand All @@ -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 {
Expand All @@ -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)
}
}
Expand Down
54 changes: 54 additions & 0 deletions Sources/remindctl/Commands/TagsCommand.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Loading