import Foundation /// **The rules of a card's `labels` list** — the pure rulebook behind the read in /// `FrontmatterFields.swift` and the write below (`FrontmatterKeys.labels` has the key's own story, /// including why it stopped being a reserved tracker key on 2026-08-09). /// /// Everything here is a static function over values: no document, no filesystem, no store. The two /// surfaces that manage labels — the card window's sidebar section and the card context menu's /// submenu — both reduce to `toggling`/`adding`/`removing` over a `[String]`, so neither of them can /// invent a second answer to "does this card already have that label?". public enum CardLabels { // MARK: - One name /// A name as the app stores it: **trimmed of surrounding whitespace, and never empty**. /// /// `nil` is "there is no name here" — an empty string, or a string that is nothing but spaces. /// That is not a label anybody meant: it would render as a blank chip, match nothing in the /// used-labels universe, and be unremovable by clicking the thing it does not draw. The empty /// rename's own rule (`title`: a name that trims to nothing writes no key at all) one collection /// down. /// /// **Interior whitespace is left exactly as typed.** `"needs review"` is a perfectly good label /// and the app has no business folding it to one word or to a hyphen — the schema stores names, /// not identifiers. public static func normalized(_ text: String) -> String? { let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } /// The comparison key: a name case-folded, so `Bug` and `bug` are one label. /// /// **Case-preserving display, case-insensitive uniqueness** (the owner's ruling). The fold is /// locale-independent on purpose — `ItemID.canonicalValue`'s posture, for its reason: this decides /// identity, and an identity that changed with the user's region would make the same two files /// mean different things on two machines. public static func canonical(_ name: String) -> String { name.lowercased() } /// Whether a case-insensitively equal name is already in the list. public static func contains(_ name: String, in list: [String]) -> Bool { guard let name = normalized(name) else { return false } let key = canonical(name) return list.contains { canonical($0) == key } } // MARK: - One list /// The list with case-insensitive duplicates collapsed, **first spelling wins**, order otherwise /// preserved. /// /// The reading applies this, so a hand-written `labels: [Bug, bug]` shows one chip rather than two /// that cannot be told apart — and the next app write to the key lands the collapsed form, which is /// the on-touch heal every other lenient field already performs by rewriting what it read. /// /// *First* spelling rather than last, unlike the duplicate-key rule one level up /// (`FrontmatterDocument.parse`'s last-wins): a key written twice is a file with two answers and the /// later one is the author's correction, while a list is one value whose members are in an order the /// author chose — so the first occurrence is the one that holds its place. public static func deduplicated(_ list: [String]) -> [String] { var seen: Set = [] var result: [String] = [] for entry in list { guard let name = normalized(entry), seen.insert(canonical(name)).inserted else { continue } result.append(name) } return result } /// `name` appended to the list, or the list unchanged when a case-insensitive twin is already /// there. /// /// **Appended, never sorted in** — the order of addition is the user's arrangement (the key's own /// no-auto-sort rule), and **the existing spelling wins** a case clash: adding `Bug` to a card that /// already carries `bug` is a no-op rather than a silent respelling, because the user is adding a /// label they already have and nothing about that gesture asks to rename it. public static func adding(_ name: String, to list: [String]) -> [String] { guard let name = normalized(name), !contains(name, in: list) else { return deduplicated(list) } return deduplicated(list) + [name] } /// The list without any case-insensitive match for `name`. public static func removing(_ name: String, from list: [String]) -> [String] { guard let name = normalized(name) else { return deduplicated(list) } let key = canonical(name) return deduplicated(list).filter { canonical($0) != key } } /// `removing` when the label is there, `adding` when it is not — the context menu's row and the /// dialog's checkbox both press exactly this. public static func toggling(_ name: String, in list: [String]) -> [String] { contains(name, in: list) ? removing(name, from: list) : adding(name, to: list) } // MARK: - Reading a parsed value /// One sequence entry's reading as a label, or `nil` when it has none. /// /// **Only a YAML string is a name**, which is a deliberate narrowing of the scalar-coercion family /// every other lenient field belongs to (`title: 2048` reads as `"2048"`). Two reasons, and the /// owner's ruling says the same thing in one sentence ("non-string entries preserved untouched but /// not rendered"): /// /// - A single-valued field has one value, so coercing it is the only way to have a reading at all. /// A list has members, and a member that is not a name can simply be **kept** — which is a better /// outcome than guessing, because nothing is lost either way. /// - `labels: [2026, ui]` is far more likely to be somebody's structured entry than a card tagged /// with a number, and the app inventing the string `"2026"` for it would make that entry /// unremovable-by-intent: the chip would say one thing and the file another. /// /// **The reading a `setLabels(_:)` will leave behind** — `nil` when the write removes the key. /// /// It exists because an undo step has to declare the after-value its write produced /// (`ExpectedField.labels`), and for this key that is not simply the list handed in: an empty list /// removes the key, whose reading is an *absence* rather than an empty list — unless entries the /// reading cannot name are holding the key open, in which case the reading really is `[]`. Two /// different bytes, two different expectations, and a step that declared the wrong one would skip /// itself the first time somebody took the last label off a card. /// /// A pure function rather than a re-read of the written document, so the prediction and the write /// are the same rule stated once — `FrontmatterDocument.setLabels` branches on exactly these two /// facts. public static func readingAfterWrite(_ names: [String], preservingEntries: Bool) -> [String]? { let names = deduplicated(names) guard !names.isEmpty || preservingEntries else { return nil } return names } /// Also `nil` for a null, a nested sequence, a mapping, and anything that trims to nothing. static func name(of entry: YAMLValue) -> String? { guard case let .string(text) = entry else { return nil } return normalized(text) } /// A parsed `labels` value's reading, or `nil` when it has **none at all** — the transform behind /// `FrontmatterDocument.labels`'s `.valid`/`.malformed` split. /// /// - a **sequence** reads as its string entries, deduplicated, in file order (`[]` for a list with /// no names in it, which is a perfectly good "no labels" and not a failure); /// - a **bare scalar** coerces to a one-element list — `labels: bug` is a card with one label, the /// single-value shape an author or an agent reaches for first, and refusing it would make the /// most forgivable spelling the one shape that renders nothing; /// - a **mapping** has no list reading and is the one shape that lands `.malformed`, rendering as /// no labels and leaving the coerce tier's trace. /// /// A non-string *scalar* at the top level (`labels: 3`) is deliberately **not** coerced, for /// `name(of:)`'s reason: it reads as no list, so it is malformed and preserved rather than becoming /// a card labelled "3". static func reading(of value: YAMLValue) -> [String]? { switch value { case let .sequence(entries): return deduplicated(entries.compactMap(name(of:))) case let .string(text): return normalized(text).map { [$0] } ?? [] default: return nil } } } // MARK: - The write side /// **The write side of `labels`** — the read side is `FrontmatterDocument.labels` /// (FrontmatterFields.swift) and the rules are `CardLabels` above. `BackgroundField.swift`'s shape and /// its reasoning, one collection over. extension FrontmatterDocument { /// The entries of the current `labels` value the reading could make no name of — what /// `setLabels(_:)` carries through untouched. /// /// Empty for every shape that is not a sequence: a bare scalar has one entry and the reading /// already made a name (or nothing) of it, and a mapping is the malformed shape a forward write /// **replaces** outright — the same malformed-value-cleared posture `icon` has ("choosing any well /// replaces it", 03-board-ui.md § Styling ▸ Controls), and the right one here, since the reader /// could not make a list of it either. var preservedLabelEntries: [YAMLValue] { guard case let .sequence(entries)? = value(for: FrontmatterKeys.labels) else { return [] } return entries.filter { CardLabels.name(of: $0) == nil } } /// Writes the card's labels — the **one** mutation every labels surface goes through. /// /// ### Canonical form /// /// A single-line **flow sequence** of double-quoted names, in the order given: /// `labels: ["bug", "needs review"]`. Flow because the span editor rewrites a key's value with one /// line's worth of text and `FrontmatterValue` has no sequence case to rewrite it with — the same /// `.raw` escape and the same narrow yield of the verbatim promise `setStyleValue` makes for /// `background`'s mapping, documented at length in `BackgroundField.swift`. Double-quoted for that /// file's reason exactly: `,` and `]` end a plain scalar in flow context, so a label with a comma /// in it would otherwise break the collection it is written into. /// /// **No sort.** The caller's order is the file's order, because the caller's order is the user's: /// labels accumulate in the sequence they were applied, and an alphabetical rewrite on every touch /// would be the app rearranging something the user arranged. /// /// ### The empty case removes the key /// /// Removing the last label removes `labels` outright rather than writing `labels: []` — the /// remove-at-default family (`collapsed`'s expand, the None well's `background`, a one-unit /// `width`): an absent key is exactly the no-labels reading, and an empty list on every unlabelled /// card would be noise the format does not need. The one exception is a list that still holds /// preserved non-name entries, which keeps the key so those entries survive. /// /// ### What survives, and what does not /// /// Entries with no name reading are carried through **at the tail**, after the names. Their /// *values* survive; their spelling does not — a block sequence collapses to flow form, quoting is /// normalized, and an entry's own inline comment is lost with the line it sat on. Tail rather than /// in place because a name list has no positions worth preserving *for the names* (the user just /// rearranged them by definition), and interleaving preserved entries back among them would make /// the user's order depend on somebody else's structured data. public mutating func setLabels(_ names: [String]) { let names = CardLabels.deduplicated(names) let preserved = preservedLabelEntries guard !names.isEmpty || !preserved.isEmpty else { remove(FrontmatterKeys.labels) return } let entries = names.map { FrontmatterValue.emitQuoted($0) } + preserved.map(Self.flowText) set(FrontmatterKeys.labels, to: .raw("[" + entries.joined(separator: ", ") + "]")) } }