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 @@ -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..<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
72 changes: 72 additions & 0 deletions Sources/remindctl/CommandHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,76 @@ enum CommandHelpers {
}
return date
}

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 @@ -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")],
Expand All @@ -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")
Expand All @@ -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()
Expand All @@ -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)
}
Expand Down
37 changes: 35 additions & 2 deletions Sources/remindctl/Commands/EditCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")],
Expand All @@ -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"),
]
Expand All @@ -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 {
Expand All @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Merge tag edits from existing reminder tags

When tag flags are used together with --title, this path parses tags from the new title (title ?? reminder.title) instead of the current reminder, so existing tags are silently dropped before --tag/--remove-tag is applied. For example, editing a reminder that already has #work with edit 1 --title "New" --tag urgent ends up with only #urgent, which causes unintended tag loss whenever users combine title updates with tag updates.

Useful? React with 👍 / 👎.

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") {
Expand All @@ -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")
}

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