Files
lanework/Kanban/App/PastedImage.swift
T
rzen 0fe92bdf38 A file copied in Finder becomes a card's attachment — ⌘V's reserved clause finally answers
The image branch's precedence ladder always had a second clause: a file URL on the
pasteboard suppresses it, "a different gesture with a different answer" that the
code deliberately declined rather than guessed at. This fills it in: one or more
file URLs paste through the same `importAttachments` a Finder drop takes — one
collision ladder, one folder refusal (`FinderDrop.partition`), one set of banners
— outranking raw image data riding beside it (a Finder-copied image file carries
both; the actual file lands, not a re-encoded copy of its bytes) while still
deferring to the app's own clipboard type. Both ⌘V surfaces read the same
`ClipboardStore.fileURLPayload`, so the board's fallback and the card window's own
branch stay in step by construction rather than by two hand-kept-in-sync checks.

Fixed a real leak in the body editor's paste yield along the way: `public.file-url`
conforms to `public.url`, which `NSTextView` legitimately reads for a pasted
hyperlink, and `NSPasteboard.availableType(from:)` matches by conformance rather
than exact type — so a Finder copy carrying a generic URL representation beside
its file URL would have been silently swallowed as text and never reached the
window's attachment branch at all. The yield now declines outright on any
file-URL pasteboard before the generic capability check runs.

Claude-Session: https://claude.ai/code/session_014PtZdPwqZuqEDLc6wZMtEy
2026-08-09 08:38:07 -04:00

214 lines
12 KiB
Swift

import CoreGraphics
import Foundation
import ImageIO
import UniformTypeIdentifiers
import os
// MARK: - PastedImage
/// **A picture on the pasteboard, as a file this app could write** (04-interactions.md ▸ Clipboard,
/// the image-data branch ruled 2026-08-09) — which flavor to take, what the landed file is called,
/// and whether the bytes travel verbatim or are re-encoded on the way.
///
/// ### Why this is a pure rule with no pasteboard in it
///
/// `NewCardTarget` and `PasteTarget`'s reason, one layer over: every clause below is a *decision*
/// about a list of type identifiers, and a decision that can be a value function should be one — the
/// menu item's `disabled`, the paste's own refusal, and the tests all read the same answer instead of
/// three hand-kept-in-sync conditions. The bytes are fetched by whoever owns the pasteboard seam
/// (`ClipboardStore`); nothing here touches `NSPasteboard`.
///
/// ### The precedence, which is the whole of the classification
///
/// 1. **The app's own clipboard type wins outright.** A Lanework copy on the pasteboard is a board
/// payload and pastes as cards or lanes exactly as it always did — an image flavor riding beside
/// it (there is none today, but a future manifest could carry a preview) must never divert ⌘V.
/// 2. **File URLs are somebody else's branch.** A Finder copy puts `public.file-url` down, sometimes
/// with an image flavor beside it, and "the pasteboard's payload is IMAGE DATA (no file URL)" is
/// the ruling's own parenthesis. A file URL is a *reference* to something the user already has
/// filed; taking a second copy of it into `attachments/` behind their back would be a different
/// gesture with a different answer, so this branch declines outright — and `ClipboardStore`'s own
/// file-URL branch (`pasteFiles`, ruled 2026-08-09) takes the reference at its word instead,
/// importing the file itself rather than a re-encoded copy of its bytes. A Finder-copied image
/// file therefore lands as the file it is, never as a second "Pasted Image.png" beside it.
/// 3. **Raw image data is the fallback**, which is the screenshot (⌃⇧⌘4), the browser's Copy Image,
/// and Preview's ⌘C.
///
/// ### The format rule
///
/// **A file-shaped flavor is kept byte for byte** — PNG, JPEG, GIF, HEIC, WebP. These are the
/// encodings a file on disk is already written in, so re-encoding one would cost either fidelity (a
/// JPEG round-tripped through PNG is bigger *and* still carries the original's artefacts) or the
/// picture itself (a GIF's animation does not survive a single-frame decode). The extension is that
/// type's own preferred one, so the landed file opens in Preview and QuickLooks in the sidebar with
/// no ceremony.
///
/// **Everything else is re-encoded to PNG** — in practice `public.tiff`, AppKit's lossless
/// interchange flavor, which is what a screenshot and a Preview copy put down beside their PNG and
/// what a great many apps offer *instead* of one. TIFF is an interchange encoding rather than a file
/// people want sitting in a card's folder: a 5K screenshot is tens of megabytes as TIFF and about a
/// tenth of that as PNG, both lossless. `.bmp` rides the same branch for the same reason.
///
/// The preference order is PNG first and TIFF last, so the overwhelmingly common paste — a screenshot
/// offering `public.png` and `public.tiff` together — lands as the PNG it already is, with no decode
/// and no re-encode at all.
public enum PastedImage {
// MARK: The name
/// **The name a pasted image lands under** — Finder's own shape for a file that arrives with no
/// name of its own ("Pasted Image.png", then "Pasted Image 2.png", …).
///
/// Only the stem is here: the extension is the flavor's (`Flavor.fileExtension`), and the
/// collision ladder is `BoardWriter.freshName`'s, reached by handing the import path a temporary
/// file with this name on it. Nothing in this app climbs a second ladder — "Finder-style rename
/// on collision is one rule wherever the app has to find a free name".
public static let baseName = "Pasted Image"
/// **The board backdrop's own stem** — the same rule one level up (03-board-ui.md § Styling ▸
/// Capabilities), so a pasted background is as recognizable in a board folder as a pasted
/// attachment is in a card's.
///
/// Its own constant rather than `baseName` reused: the two files land in different folders for
/// different reasons, and a board folder holding something called "Pasted Image.png" would say
/// nothing about what it is for. `FacetsGenerator.fileName` is the sibling this is modeled on.
public static let backgroundBaseName = "Pasted Background"
// MARK: The flavor
/// One readable image payload on the pasteboard: where to read it from, what to write, and what
/// to call it.
public struct Flavor: Equatable, Sendable {
/// The pasteboard type identifier the bytes come from.
public let type: String
/// The landed file's extension — the flavor's own for a verbatim write, `png` for a
/// converted one.
public let fileExtension: String
/// Whether the bytes are re-encoded on the way (see the type comment's format rule).
public let convertsToPNG: Bool
/// The file name a paste of this flavor mints, before the Finder ladder ever sees it.
public var fileName: String { "\(PastedImage.baseName).\(fileExtension)" }
/// The board-backdrop file name for the same flavor.
public var backgroundFileName: String { "\(PastedImage.backgroundBaseName).\(fileExtension)" }
}
// MARK: Classification
/// The flavors kept verbatim, **in this app's preference order** — not the pasteboard's, which
/// is the *owner's* ranking of what it thinks a taker wants and has no idea a file is about to
/// be written.
///
/// PNG leads because it is lossless, universally readable, and the flavor a screenshot already
/// carries. JPEG follows so a photograph copied out of a browser lands as the JPEG it is rather
/// than as a PNG several times its size. GIF, HEIC and WebP are here so that a source offering
/// only one of them is still a paste rather than a refusal.
public static let verbatimTypes: [UTType] = [.png, .jpeg, .gif, .heic, .webP]
/// The flavors re-encoded to PNG — the interchange bitmaps (see the type comment).
public static let convertedTypes: [UTType] = [.tiff, .bmp]
/// What this pasteboard offers the image branch, or `nil` when the branch does not apply.
///
/// - Parameter hasBoardItems: whether the app's own clipboard type is present and readable —
/// clause 1 of the precedence. Passed in rather than read here because deciding *that* is
/// `ClipboardManifest`'s job and this type has no pasteboard.
/// - Parameter types: every type identifier the pasteboard currently carries.
public static func flavor(hasBoardItems: Bool, types: [String]) -> Flavor? {
guard !hasBoardItems, !carriesFileURL(types) else { return nil }
let offered = Set(types)
for type in verbatimTypes where offered.contains(type.identifier) {
// A registered type with no preferred extension is not something a file can be named
// after; skipping it lets the ladder fall through to the converted branch rather than
// minting "Pasted Image." with nothing after the dot.
guard let ext = type.preferredFilenameExtension else { continue }
return Flavor(type: type.identifier, fileExtension: ext, convertsToPNG: false)
}
for type in convertedTypes where offered.contains(type.identifier) {
return Flavor(type: type.identifier, fileExtension: "png", convertsToPNG: true)
}
return nil
}
/// Whether the pasteboard is carrying a file reference — clause 2 of the precedence.
///
/// Conformance rather than equality with `public.file-url`, for `FinderDrop.isDirectory
/// (typeIdentifiers:)`'s reason: a source is free to declare a subtype of it, and the rule is
/// about what the value *is*. A type the system does not know is not a file URL, which is the
/// same optimistic reading a drag's unknown types get.
public static func carriesFileURL(_ types: [String]) -> Bool {
types.contains { UTType($0)?.conforms(to: .fileURL) ?? false }
}
// MARK: The bytes
private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "paste-image")
/// The bytes this flavor writes, given what the pasteboard handed over — `raw` itself for a
/// verbatim flavor, a PNG re-encoding for a converted one, and `nil` when the payload turns out
/// not to be a decodable image at all.
///
/// A `nil` here is the honest refusal: the pasteboard *declared* a type it cannot back up, and
/// writing an unreadable file into somebody's card folder is worse than doing nothing. The
/// caller treats it exactly as it treats an empty pasteboard.
public static func encode(_ raw: Data, as flavor: Flavor) -> Data? {
guard flavor.convertsToPNG else { return raw.isEmpty ? nil : raw }
return pngData(from: raw)
}
/// A bitmap payload re-encoded as PNG, through ImageIO.
///
/// **ImageIO rather than `NSBitmapImageRep`**, which is the same call `BoardBackdrop.decode`
/// makes and for the same reasons: it is the framework that actually owns the codecs, it is
/// `Sendable`-clean and main-actor-free, and it needs no AppKit image cache in the middle. The
/// full image is decoded rather than a thumbnail — this is a *conversion*, and downsampling a
/// picture the user pasted would silently cost them resolution they never agreed to lose.
///
/// Alpha survives, because a PNG destination writing a CGImage with an alpha channel keeps it —
/// which matters for exactly the payload this branch sees most, a screenshot of a rounded window.
public static func pngData(from data: Data) -> Data? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let image = CGImageSourceCreateImageAtIndex(
source, 0, [kCGImageSourceShouldCacheImmediately: true] as CFDictionary
)
else {
logger.debug("pasteboard bitmap could not be decoded")
return nil
}
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
output, UTType.png.identifier as CFString, 1, nil
) else { return nil }
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else {
logger.debug("pasteboard bitmap could not be re-encoded as PNG")
return nil
}
return output as Data
}
// MARK: Which attachments can be a hero
/// Whether an attachment named `name` is one **"Set as Hero" may point at** — an image, by its
/// name's own extension (05-card-window.md ▸ Attachments; 03-board-ui.md § Card face ▸ Hero
/// image).
///
/// **By extension rather than by opening the file**, deliberately. The context menu is built
/// while the pointer is going down on a row, and the sidebar may be showing a hundred of them;
/// a per-row `CGImageSourceCreateWithURL` to decide whether a menu row is offered is exactly the
/// kind of disk touch the card window keeps out of a body evaluation. The cost of being wrong is
/// nothing either way: a name whose extension lies renders as no band at all (`CardHeroImage`'s
/// structural degrade), and an image the system does not recognize by extension is simply not
/// offered the row — the `hero` key is still hand-writable, which is what it was born as.
public static func isImageName(_ name: String) -> Bool {
let ext = (name as NSString).pathExtension
guard !ext.isEmpty, let type = UTType(filenameExtension: ext) else { return false }
return type.conforms(to: .image)
}
}