A reserved key comes to life — the card window's sidebar grows a Labels section, and labels stops being somebody else's
`labels` has been a reserved tracker key since the rewrite: preserved verbatim, never interpreted, drawn only as an anonymous row in the Details section beside `assignees` and `due`. The owner's cards claim it for first-party use, so it joins the schema — read leniently (a list of names, a bare scalar coercing to one, a mapping malformed and preserved), written canonically (a quoted flow list in the order the user arranged, no auto-sort), and removed outright when the last label goes, the way an expanded lane drops `collapsed`. Identity is case-insensitive and display is case-preserving, so a card carries `bug` once however many ways the board spells it, and entries the reading cannot name ride through the write untouched at the tail. The section sits second, above Details — which is the point rather than a layout preference: Details is where keys the app does *not* own are shown, and this key just stopped being one. Rows rather than chips, because the sidebar is twenty-six characters wide. The add field autocompletes against the board's own used-labels universe, derived from every live and trashed card with no store beside the files, and says out loud when Return would mint a word the board has never used. Writes ride a `.relabel` operation of their own, because the commit composer has said "Relabel card 'X'" since long before there was a control to press — and now the undo row says it too. Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
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<String> = []
|
||||
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: ", ") + "]"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user