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:
@@ -155,7 +155,12 @@ extension FrontmatterDocument {
|
||||
/// preserved subkey's number or timestamp is emitted by the same code that writes `order` and
|
||||
/// `created`. Nested collections recurse, which keeps an unknown subkey holding a list from
|
||||
/// being flattened into its `description`.
|
||||
private static func flowText(_ value: YAMLValue) -> String {
|
||||
///
|
||||
/// **Internal rather than private since 2026-08-09**: `labels` writes a flow *sequence*
|
||||
/// (`LabelsField.swift`) and carries preserved non-name entries through it, which is the identical
|
||||
/// question one collection over — a second copy of this switch would be a second opinion about how
|
||||
/// somebody else's YAML is re-emitted.
|
||||
static func flowText(_ value: YAMLValue) -> String {
|
||||
switch value {
|
||||
case .null: "null"
|
||||
case let .bool(value): FrontmatterValue.bool(value).yamlText
|
||||
|
||||
@@ -838,6 +838,7 @@ public enum BoardLoader: Sendable {
|
||||
icon: document.icon,
|
||||
iconColor: document.iconColor,
|
||||
hero: document.hero,
|
||||
labels: document.labels,
|
||||
order: order,
|
||||
attachments: entry.attachments,
|
||||
commentCount: entry.commentCount,
|
||||
@@ -1150,6 +1151,7 @@ public enum BoardLoader: Sendable {
|
||||
icon: document.icon,
|
||||
iconColor: document.iconColor,
|
||||
hero: document.hero,
|
||||
labels: document.labels,
|
||||
order: order,
|
||||
attachments: attachments,
|
||||
commentCount: commentCount,
|
||||
|
||||
@@ -271,6 +271,25 @@ public struct Card: Identifiable, Sendable, Equatable {
|
||||
/// key, and the bytes stay as written.
|
||||
public let hero: FieldValue<String>
|
||||
|
||||
/// **The card's labels** — the names the card is tagged with (`FrontmatterKeys.labels`, whose doc
|
||||
/// comment carries the key's own story; `CardLabels` has the rules; the reading is
|
||||
/// `FrontmatterDocument.labels`).
|
||||
///
|
||||
/// **Card-level only**, `hero`'s posture and for its kind of reason: a label is a property of a
|
||||
/// piece of work, and neither a lane nor a board is one. The key on a lane or a board stays an
|
||||
/// ordinary unknown one, preserved verbatim and shown in no sidebar the way it always was.
|
||||
///
|
||||
/// The *shape* rather than the answer, so the coerce tier can report a value that had no list
|
||||
/// reading at all (a mapping); `.missing` and `.valid([])` are both "no labels" and render
|
||||
/// identically, which is why nothing in the app branches on the difference — but the two are
|
||||
/// distinct bytes on disk and the field keeps them apart.
|
||||
///
|
||||
/// **The board face does not draw these** (as of the activation, 2026-08-09). Chips on the card
|
||||
/// face are their own design question — the attachments and comments chips set that vocabulary and
|
||||
/// a label list is a different shape of thing — so this rides in the snapshot for the card window's
|
||||
/// sidebar, the context menu's submenu, and the board-wide used-labels universe those two share.
|
||||
public let labels: FieldValue<[String]>
|
||||
|
||||
/// Rank within its lane, ascending = top-to-bottom — the reading, not necessarily the key. See
|
||||
/// `Lane.order`'s doc comment; the same reasoning applies here, and a card is where it matters
|
||||
/// most: the minimum legal agent card is a `mkdir` plus one `index.md` with no `order` at all
|
||||
|
||||
@@ -3025,6 +3025,24 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case migrateTombstone(title: String?)
|
||||
case style(title: String?) // updateIndex on behalf of styling flows (03-board-ui.md)
|
||||
|
||||
/// **A card's `labels` list being rewritten** — the sidebar section's chips and the context menu's
|
||||
/// submenu, the two surfaces the key's 2026-08-09 activation shipped with
|
||||
/// (`FrontmatterKeys.labels`; `BoardStore.setLabels(_:onCard:on:)`).
|
||||
///
|
||||
/// **Its own case rather than a fold into `.style`**, on the vocabulary's standing reasoning. A
|
||||
/// restyle picks an appearance; this edits what the card *is about*, and 06-history-undo.md's
|
||||
/// commit-message composer has had a separate word for it since long before there was a control —
|
||||
/// "Relabel card 'X'" (§ Commit messages, named there among the three keys "invisible in the UI
|
||||
/// though they are"). The word already existed; this is the gesture arriving to claim it. A banner
|
||||
/// telling someone the app "couldn't style 'Fix login'" after they clicked a label row would name a
|
||||
/// control they never touched.
|
||||
///
|
||||
/// **One case for both directions**, unlike `.collapse`/`.expand`: adding and removing a label are
|
||||
/// the same control pressed twice — a chip's ⊗ and a menu row's checkmark both just rewrite the
|
||||
/// list — where collapse and expand are two menu rows with two words. `title` is the card's, filled
|
||||
/// in by `updateIndex` off the document it reads.
|
||||
case relabel(title: String?)
|
||||
|
||||
/// **A generated board background landing** — the PNG written into the board folder and the
|
||||
/// `background` mapping's two subkeys pointed at it, one bracket
|
||||
/// (`BoardStore.applyGeneratedBackground`; `FacetsGenerator`).
|
||||
@@ -3359,6 +3377,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case .purge: .purge(title: title)
|
||||
case .migrateTombstone: .migrateTombstone(title: title)
|
||||
case .style: .style(title: title)
|
||||
case .relabel: .relabel(title: title)
|
||||
case .resize: .resize(title: title)
|
||||
case .collapse: .collapse(title: title)
|
||||
case .expand: .expand(title: title)
|
||||
@@ -3410,7 +3429,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
// reading to be about — and `.deleteComment`'s move into `comments/.trash/` stamps for the
|
||||
// plain container reason its board-level twin does.
|
||||
case .createBoard, .createLane, .createCard, .move, .copy, .delete, .purge, .migrateTombstone,
|
||||
.style, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
|
||||
.style, .relabel, .resize, .collapse, .expand, .rename, .duplicateBoard, .saveAsTemplate, .shareBoard, .paste,
|
||||
.exportBoard, .importBoard,
|
||||
.importAttachment,
|
||||
.listAttachments, .removeAttachment, .relocateLooseFile, .tidyBackgroundImage, .agentGuide,
|
||||
@@ -3441,6 +3460,7 @@ public enum WriteOperation: Sendable, Equatable, CustomStringConvertible {
|
||||
case let .purge(title): Self.phrase("purge", title)
|
||||
case let .migrateTombstone(title): Self.phrase("migrate the legacy 'deleted' key on", title)
|
||||
case let .style(title): Self.phrase("style", title)
|
||||
case let .relabel(title): Self.phrase("relabel", title)
|
||||
case .setBoardBackground: "set this board's background"
|
||||
case let .resize(title): Self.phrase("resize", title)
|
||||
case let .collapse(title): Self.phrase("collapse", title)
|
||||
|
||||
@@ -593,6 +593,42 @@ public enum FrontmatterKeys {
|
||||
/// the reason it always did — it is Lanework's to interpret, not an unknown key riding along.
|
||||
public static let hero = "hero"
|
||||
|
||||
/// **A card's labels** (01-storage-format.md § Frontmatter's card table — the amendment this key's
|
||||
/// activation owes; `CardLabels`, `FrontmatterDocument.labels`) — a YAML list of names:
|
||||
/// `labels: ["bug", "ui"]`.
|
||||
///
|
||||
/// ### It used to be reserved, and that reversal is the design event
|
||||
///
|
||||
/// Through 2026-08-09 this was one of the **reserved tracker keys** beside `assignees`, `due`,
|
||||
/// `remote` and `remote-state` (01 § Enhanced schema): "the pathfinder shipped them natively; the
|
||||
/// rewrite reserves them instead", preserved verbatim, never interpreted, and shown only as an
|
||||
/// anonymous row in the card window's Details section like any other unknown key. The owner's cards
|
||||
/// ("in card window sidebar add ability to manage card's labels", "add labels submenu to card
|
||||
/// context menu") claim it for **first-party use**, which retires the reservation for this one name
|
||||
/// — `assignees`, `due` and the two `remote` keys are untouched and stay exactly as reserved as they
|
||||
/// were.
|
||||
///
|
||||
/// **It therefore joins `schemaOwned`**, which is the whole of what stops the Details section
|
||||
/// drawing a second, raw copy of a field the sidebar now renders properly (`CardDetails.rows`
|
||||
/// subtracts exactly that set). `hero`'s precedent, and for `hero`'s reason: it is Lanework's to
|
||||
/// interpret, not an unknown key riding along.
|
||||
///
|
||||
/// ### The grammar
|
||||
///
|
||||
/// **Read leniently, written canonically** — the shape of every lenient field here, one collection
|
||||
/// up. A sequence reads as its string entries in file order; a bare scalar coerces to a
|
||||
/// one-element list (`labels: bug`); a mapping has no list reading at all and is `.malformed`.
|
||||
/// Names are **case-preserving for display and case-insensitively unique within a card**, and
|
||||
/// entries the reading cannot make a name of are preserved on the way out but never rendered
|
||||
/// (`CardLabels`).
|
||||
///
|
||||
/// **Written as a flow list, in the user's own order** — no auto-sort: the order is the order of
|
||||
/// addition, which is a thing the user arranged and the app has no business restating
|
||||
/// alphabetically. **Removing the last label removes the key**, the remove-at-default family's rule
|
||||
/// beside `collapsed`'s expand and the None well's `background`: an absent key is the no-labels
|
||||
/// reading, so `labels: []` written by the app would be noise every unlabelled card had to carry.
|
||||
public static let labels = "labels"
|
||||
|
||||
public static let created = "created"
|
||||
public static let modified = "modified"
|
||||
public static let modifiedBy = "modified-by"
|
||||
@@ -620,7 +656,8 @@ public enum FrontmatterKeys {
|
||||
public static let kind = "kind"
|
||||
|
||||
/// The reserved tracker keys — `remote` (board, card) and `remote-state` (lane) — of the enhanced
|
||||
/// schema's future connectors (01-storage-format.md § Enhanced schema).
|
||||
/// schema's future connectors (01-storage-format.md § Enhanced schema). **`labels` left this
|
||||
/// family on 2026-08-09** when the owner claimed it for first-party use; see that key above.
|
||||
///
|
||||
/// **Named here without joining `schemaOwned`**, which is the ruling rather than an oversight: 01
|
||||
/// says the app "treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in
|
||||
@@ -652,7 +689,7 @@ public enum FrontmatterKeys {
|
||||
public static let author = "author"
|
||||
|
||||
public static let schemaOwned: Set<String> = [
|
||||
schema, title, order, width, collapsed, hero, created, modified, modifiedBy, deleted,
|
||||
schema, title, order, width, collapsed, hero, labels, created, modified, modifiedBy, deleted,
|
||||
background, icon, iconColor, kind,
|
||||
]
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ extension FrontmatterDocument {
|
||||
record(FrontmatterKeys.icon, icon)
|
||||
record(FrontmatterKeys.iconColor, iconColor)
|
||||
record(FrontmatterKeys.hero, hero)
|
||||
record(FrontmatterKeys.labels, labels)
|
||||
record(FrontmatterKeys.kind, kind)
|
||||
return found
|
||||
}
|
||||
@@ -270,6 +271,24 @@ extension FrontmatterDocument {
|
||||
}
|
||||
}
|
||||
|
||||
/// **A card's labels** (`FrontmatterKeys.labels`, whose doc comment carries the key's own story —
|
||||
/// including the 2026-08-09 reversal that took it off the reserved-tracker-keys list; the rules
|
||||
/// themselves are `CardLabels`).
|
||||
///
|
||||
/// The lenient family's shape, one collection up. A **sequence** reads as its string entries in
|
||||
/// file order, case-insensitively deduplicated with the first spelling winning. A **bare scalar**
|
||||
/// coerces to a one-element list (`labels: bug`) — the same "any scalar has a sensible reading"
|
||||
/// instinct `title` has, narrowed to strings for `CardLabels.name(of:)`'s stated reason. A
|
||||
/// **mapping**, and a non-string scalar, have no list reading at all: `.malformed`, rendering as no
|
||||
/// labels, bytes untouched, and a coerce-tier trace left behind.
|
||||
///
|
||||
/// A list holding nothing the reader can name — `labels: [{a: 1}]` — is `.valid([])` rather than
|
||||
/// malformed: the author wrote a list, which is the right shape, and it happens to contain no
|
||||
/// names. Those entries survive the next write (`FrontmatterDocument.setLabels`).
|
||||
public var labels: FieldValue<[String]> {
|
||||
read(FrontmatterKeys.labels) { value, _ in CardLabels.reading(of: value) }
|
||||
}
|
||||
|
||||
public var created: FieldValue<Date> { read(FrontmatterKeys.created) { value, _ in Self.date(value) } }
|
||||
public var modified: FieldValue<Date> { read(FrontmatterKeys.modified) { value, _ in Self.date(value) } }
|
||||
public var deleted: FieldValue<Date> { read(FrontmatterKeys.deleted) { value, _ in Self.date(value) } }
|
||||
|
||||
@@ -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