import Foundation /// **The one funnel every label gesture goes through** — the write plus the recents record, so no /// anchor can do one without the other (`StyleCommand`'s shape and its reason exactly: /// `StyleEditor.swift`). /// /// Two anchors exist today and they are in different windows — the card window's sidebar section and /// the board card menu's `labels` submenu, plus that submenu's More… dialog. Every one of them ends /// up here, which is what keeps "the MRU is updated on every label apply" a fact rather than three /// call sites' good intentions. /// /// ### Recording the add half only /// /// A **removal records nothing**. The MRU exists to answer "what is this user reaching for", and /// taking `bug` off a card is evidence of the opposite — recording it would float a label to the top /// of the very menu the user is trying to get away from. `StyleRecents`' own "the None well is not a /// colour" carve-out, one field over (`LabelRecents`). /// /// ### Spelling is the board's, not the typist's /// /// A name typed into the sidebar's field or the dialog's create box is resolved against the board's /// own universe first (`LabelIndex.canonicalSpelling(of:)`), so typing `BUG` onto a board that already /// says `bug` tags the card `bug` rather than minting a second variant nothing can tell apart. Only a /// genuinely new name keeps the typist's capitalisation — which is exactly right, because for a new /// label the typist *is* the board. @MainActor enum LabelCommand { /// Adds or removes `name` on one card, whichever the card's current list calls for — the context /// menu's rows and the dialog's checkboxes. /// /// - Parameter undo: the issuing window's own stack, for the one anchor that has one — the card /// window's sidebar (13-native-undo.md ▸ Rules ▸ two levels). `nil`, which every board-side /// anchor passes, is the board's stack. /// - Returns: whether bytes reached disk. @discardableResult static func toggle( _ name: String, onCard cardID: ItemID, in store: BoardStore, recents: LabelRecents, on undo: CardWindowUndo? = nil ) -> Bool { let current = store.labels(ofCard: cardID) guard CardLabels.contains(name, in: current) else { return add(name, onCard: cardID, in: store, recents: recents, on: undo) } return store.setLabels(CardLabels.removing(name, from: current), onCard: cardID, on: undo) } /// Adds `name` to one card — the sidebar's add field and the dialog's create box, which both mean /// "put this on the card" rather than "flip whatever it is now". /// /// A name the card already carries is a no-op that **still records**: the user reached for it, and /// the MRU's whole subject is what they reach for. @discardableResult static func add( _ name: String, onCard cardID: ItemID, in store: BoardStore, recents: LabelRecents, on undo: CardWindowUndo? = nil ) -> Bool { guard let name = resolved(name, in: store) else { return false } recents.record(name) let current = store.labels(ofCard: cardID) return store.setLabels(CardLabels.adding(name, to: current), onCard: cardID, on: undo) } /// Removes `name` from one card — the chip's ⊗. Records nothing (see the type comment). @discardableResult static func remove( _ name: String, fromCard cardID: ItemID, in store: BoardStore, recents _: LabelRecents, on undo: CardWindowUndo? = nil ) -> Bool { let current = store.labels(ofCard: cardID) return store.setLabels(CardLabels.removing(name, from: current), onCard: cardID, on: undo) } /// A typed name in the board's own spelling — see the type comment. `nil` for a name that trims to /// nothing, which is not a label anybody meant. static func resolved(_ name: String, in store: BoardStore) -> String? { guard let name = CardLabels.normalized(name) else { return nil } return store.labelIndex.canonicalSpelling(of: name) ?? name } }