Files
lanework/Kanban/Storage/LabelsField.swift
T
rzen da37ed61bf 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
2026-08-09 12:06:01 -04:00

225 lines
12 KiB
Swift

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: ", ") + "]"))
}
}