Files
lanework/Kanban/App/ClipboardManifest.swift
T
rzen ca5d45156b A picture off the pasteboard becomes a card's file, its hero, or the board's backdrop
⌘V grows an image-data branch, below the app's own clipboard format and refused
outright while a file URL is on the pasteboard: a screenshot or a browser's Copy
Image lands as "Pasted Image.png" in the anchor card's attachments/, through the
very import path Finder file drops and ⇧⌘A take — one bracket, one Finder-style
collision ladder, one set of banners, and the same silence a drop's arrival has.
A card window's ⌘V pastes onto its own card; a focused text field still wins the
selector natively. A file-shaped flavor travels byte for byte, PNG preferred when
several are offered; TIFF and BMP are re-encoded to PNG, being interchange
encodings rather than files anyone wants in a folder.

The hero key gets the setter it was born owing: "Set as Hero" on any image row of
the attachment list, "Remove Hero" on the row that holds it, with menu-bar twins
so the context entry is nobody's only home. It writes as a restyle — one key, one
bracket, one invertible step on the window's own stack — and replaces rather than
refusing, because a card has one hero and the row that has it says Remove instead.

Edit ▸ Paste as Board Background is the same payload's other destination, taking
the existing background.image convention at its word: the picture into the board
folder as "Pasted Background.png", the colour subkey untouched, the generator's
overwrite-our-own-name rule inherited and its echo memo taught to tell the two
producers apart.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 02:43:53 -04:00

266 lines
12 KiB
Swift

import AppKit
import Foundation
import UniformTypeIdentifiers
// MARK: - The clipboard type
//
// `UTType.laneworkClipboard` — what a Lanework copy puts on the pasteboard under its own type, the
// JSON `ClipboardManifest` below (04-interactions.md ▸ Clipboard: "the pasteboard carries a JSON
// manifest + plain text"). Declared in `Info.plist` beside the two drag types, for the same reason
// those are: a payload nobody has declared is a payload the system will not carry. The constant
// itself lives with its two siblings in `PasteboardTypes.swift`, exported once by the app that
// owns it.
// MARK: - The manifest
/// The JSON half of the hybrid clipboard (04-interactions.md ▸ Clipboard).
///
/// **It is self-describing twice over**, and both halves earn their keep:
///
/// - `copyID` ties the pasteboard to a staging directory — `<Application Support>/Clipboard/<copyID>/`,
/// the full folder snapshots a paste reproduces byte-for-byte from — and to a pending cut. It is
/// also the whole of "the snapshot survives relaunch exactly as long as the pasteboard still points
/// at it": a sweep keeps the one directory this id names and collects every other.
/// - Each `Entry` embeds the item's complete `index.md` text as **identification metadata**
/// (04-interactions.md ▸ Clipboard, re-ruled 2026-07-29): menu validation, the refusal's wording,
/// and the plain-text flavor read it. It is emphatically **not** a materialization source — a paste
/// whose staged snapshot is missing or unreadable refuses whole and writes nothing, because "an item
/// arrives whole — index, attachments, loose files — or not at all".
///
/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
/// board-XOR-trash invariants are exactly the ones those two types already carry. Their raw
/// spellings are pasteboard API — a manifest written before a quit is decoded after the relaunch.
///
/// `entries` are in the order the copy read them — flatten order on the live side ("lane `order`,
/// then card `order`"), the trash's own sorted order on the trashed side (`SelectionGrammar.order`)
/// — which is the order a paste inserts them in.
public struct ClipboardManifest: Codable, Sendable, Equatable {
/// Bumped only if the shape below stops being readable by an older build. Nothing branches on it
/// today; a manifest whose version this build does not know is simply refused (`init?(data:)`),
/// which degrades to "there is nothing to paste" rather than to a wrong paste.
public static let currentVersion = 1
public var version: Int
/// The staging directory's name, and the pending cut's identity.
public var copyID: String
/// The **source** board's root folder. Paste needs it for nothing structural — the destination
/// store owns every write — but a cut's move reads its folders from there, and "pasting into the
/// source board is supported and is the within-board lane duplicate" is a claim about this value.
public var boardRoot: String
public var kind: SelectionKind
public var container: ItemContainer
public var entries: [Entry]
/// One copied item: where its snapshot is staged, what it is called, and its bytes.
public struct Entry: Codable, Sendable, Equatable {
/// The source item's own UUID. Never reused at the destination — every materialization mints
/// fresh identities — but it is what names the staged folder and what a cut's move resolves.
public var id: String
/// The staged subfolder under `<staging>/<copyID>/`, which is `id` itself: a selection is a
/// set, so its members' UUIDs are unique within one copy, and `BoardWriter.copyItem` requires
/// a UUID-shaped source folder — a positional name would be refused as a stray.
public var folder: String
/// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a
/// value (03-board-ui.md § Card face). Feeds the plain-text representation and the refused
/// paste's banner, which names the offending entry from exactly this.
public var title: String?
/// The complete `index.md` at copy time — **identification metadata, never materialized**
/// (see the type comment). Kept because it is what lets the app answer "what was on the
/// clipboard" without touching the staging store: the plain-text flavor and a refusal's wording
/// both come from here, and both have to work when the snapshot is exactly what is missing.
public var index: String
/// How many files the item's own `attachments/` held. Zero for a lane, which has none; a
/// lane's attachments are its cards' and are counted there.
///
/// Identification metadata like the rest of the entry. It used to feed the degraded paste's
/// loss accounting ("Pasted 'Fix login' without its 3 attachments"), which is retired with the
/// degraded paste itself — an item now arrives whole or not at all, so there is no partial
/// arrival left to count.
public var attachmentCount: Int
/// A **lane** entry's cards, index text and all — "a lane entry embeds its cards' too,
/// attachment-less". Empty for a card entry.
///
/// **Exactly the lane's cards**, which needs no filter: "a lane carries exactly its cards —
/// the trash is board-level, so there is nothing lane-nested to strip or carry"
/// (04-interactions.md ▸ Drag and drop, resettled 2026-07-28). Like the lane's own `index`, the
/// cards' text is identification metadata: it describes what the copy held, and nothing
/// materializes from it.
public var cards: [Card]
/// One card inside a copied lane.
public struct Card: Codable, Sendable, Equatable {
public var id: String
public var title: String?
public var index: String
public var attachmentCount: Int
public init(id: String, title: String?, index: String, attachmentCount: Int) {
self.id = id
self.title = title
self.index = index
self.attachmentCount = attachmentCount
}
}
/// Every file this entry's subtree carried in an `attachments/` — its own plus, for a lane, its
/// cards'. Identification metadata; nothing gates on it since the degraded paste retired.
public var totalAttachmentCount: Int {
attachmentCount + cards.reduce(0) { $0 + $1.attachmentCount }
}
public init(
id: String,
folder: String,
title: String?,
index: String,
attachmentCount: Int,
cards: [Card] = []
) {
self.id = id
self.folder = folder
self.title = title
self.index = index
self.attachmentCount = attachmentCount
self.cards = cards
}
}
public init(
version: Int = ClipboardManifest.currentVersion,
copyID: String,
boardRoot: URL,
kind: SelectionKind,
container: ItemContainer,
entries: [Entry]
) {
self.version = version
self.copyID = copyID
self.boardRoot = boardRoot.path
self.kind = kind
self.container = container
self.entries = entries
}
public var rootURL: URL { URL(fileURLWithPath: boardRoot, isDirectory: true) }
/// The secondary representation — one title per line, untitled items rendered as the board
/// renders them, so a ⌘V into any text field does something sane. `DragPayload.plainText`'s rule,
/// because it is the same question asked of the other transfer mechanism.
public var plainText: String {
entries.map { $0.title ?? "Untitled" }.joined(separator: "\n")
}
// MARK: Coding
public func encoded() -> Data? {
try? JSONEncoder().encode(self)
}
public init?(data: Data) {
guard let decoded = try? JSONDecoder().decode(ClipboardManifest.self, from: data),
decoded.version == Self.currentVersion,
!decoded.entries.isEmpty
else { return nil }
self = decoded
}
}
// MARK: - The pasteboard seam
/// The one thing `ClipboardStore` needs from `NSPasteboard`, behind a protocol.
///
/// It exists for testability and for nothing else: every rule the clipboard owns — the sweep, the
/// at-most-one-snapshot invariant, takeover detection, the cut's voiding — is a rule *about*
/// `changeCount` and the bytes under one type, and a suite that reached for `NSPasteboard.general`
/// would be racing every other app on the machine (and every other test in the run).
///
/// `changeCount` is the whole of takeover detection: it is a machine-wide counter that AppKit bumps
/// on every `clearContents()` by anyone, so a value that moved without this store moving it means
/// somebody else owns the pasteboard now (04-interactions.md ▸ Clipboard: "voided if another app
/// takes the pasteboard").
@MainActor
public protocol ClipboardPasteboard: AnyObject {
var changeCount: Int { get }
/// The bytes under the clipboard type, or `nil` when the pasteboard holds someone else's content.
func manifestData() -> Data?
/// Replaces the pasteboard with **one** item carrying both representations, and answers the
/// resulting `changeCount`.
@discardableResult
func write(manifest: Data, text: String) -> Int
/// **Every type identifier the pasteboard currently carries** — what the image-data branch
/// classifies (04-interactions.md ▸ Clipboard, ruled 2026-08-09; `PastedImage.flavor`).
///
/// A list rather than a set of yes/no questions, because the *rule* is a decision over a list and
/// belongs in one place: adding a flavor to `PastedImage.verbatimTypes` must not also mean adding
/// a method here. It subsumes `manifestData()`'s question too, and does not replace it — the
/// manifest is read as bytes and decoded, which a type list cannot answer.
func availableTypes() -> [String]
/// The bytes under one type, whatever it is — the general read behind `manifestData()`'s
/// specific one, added for the image branch (which knows its type only at runtime, off the
/// classification above).
func data(forType type: String) -> Data?
}
/// The real pasteboard.
///
/// One item with two representations, written directly rather than through SwiftUI's
/// `onCopyCommand`: the item structure has to be exactly this — a known `copyID` under a known type,
/// with the plain text beside it rather than in a second item — and a mechanism that decides the
/// shape for us could not promise that.
@MainActor
public final class SystemPasteboard: ClipboardPasteboard {
private let pasteboard: NSPasteboard
public init(_ pasteboard: NSPasteboard = .general) {
self.pasteboard = pasteboard
}
private static let type = NSPasteboard.PasteboardType(UTType.laneworkClipboard.identifier)
public var changeCount: Int { pasteboard.changeCount }
public func manifestData() -> Data? {
pasteboard.data(forType: Self.type)
}
/// `NSPasteboard.types` — the **first item's** types, which is what "the clipboard's payload"
/// means for every producer this branch cares about: a screenshot, a browser's Copy Image, a
/// Finder copy, and this app's own write are all single-item writes. A multi-item pasteboard's
/// later items are deliberately not consulted; pasting the second image of a five-image copy is a
/// gesture nobody has asked for and would need a target grammar of its own.
public func availableTypes() -> [String] {
(pasteboard.types ?? []).map(\.rawValue)
}
public func data(forType type: String) -> Data? {
pasteboard.data(forType: NSPasteboard.PasteboardType(type))
}
@discardableResult
public func write(manifest: Data, text: String) -> Int {
pasteboard.clearContents()
let item = NSPasteboardItem()
item.setData(manifest, forType: Self.type)
item.setString(text, forType: .string)
pasteboard.writeObjects([item])
return pasteboard.changeCount
}
}