Implement attachment import and listing
importAttachments lands files into a card's attachments/ (created on first import — the one folder the app ever makes there), never overwriting and never refusing a name: collisions auto-rename Finder-style, counting up from 2, with a same-named subfolder blocking a name exactly like a file. Sources are validated before any copy; a failed copy removes the partial destination and throws naming the source file; a multi-file batch stops at the first failure with earlier files staying landed. listAttachments is the flat surface: top-level regular files only, Finder-sorted, subfolders and hidden entries excluded and untouched; a missing attachments/ lists empty. 11 new unit tests; 245 total green. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
This commit is contained in:
@@ -814,6 +814,165 @@ public enum BoardWriter: Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Attachments
|
||||
|
||||
/// The one folder this app ever creates under a card — every other subfolder under
|
||||
/// `attachments/` is tolerated but never made or named by the app (01-storage-format.md §
|
||||
/// Attachments). Internal rather than `private`: `importAttachments` and `listAttachments`
|
||||
/// must never disagree about which folder holds a card's files.
|
||||
static let attachmentsFolderName = "attachments"
|
||||
|
||||
/// Imports files into a card's `attachments/` folder, creating it on first import — the
|
||||
/// write side of 01-storage-format.md § Attachments. **Never refuses the drop**: a name
|
||||
/// already taken is auto-renamed Finder-style (`shot.png` → `shot 2.png` → `shot 3.png`, …)
|
||||
/// rather than overwritten or bounced.
|
||||
///
|
||||
/// A multi-file drop imports **in order, one finished copy at a time** — not a transaction
|
||||
/// across the batch: the first failure stops it and throws naming the failing source file,
|
||||
/// but every file already imported stays landed, because each import already completed as
|
||||
/// its own write before the next one starts. Returns the landed names, in input order, for
|
||||
/// exactly the files that made it in.
|
||||
///
|
||||
/// The order of checks is the contract:
|
||||
///
|
||||
/// 1. **`cardFolder` must be an existing, UUID-shaped directory** — attachments belong to
|
||||
/// cards, the same shape guard `deleteItem`/`restoreItem`/`purgeItem` lean on
|
||||
/// (`checkIsUUIDShaped`): a lane or a board root is refused before anything else happens.
|
||||
/// 2. **`attachments/` is created if missing** (`.io` naming `cardFolder` on failure) — the
|
||||
/// one exception to "subfolders are never created by the app" (§ Attachments); every
|
||||
/// other folder under it is the user's or a hand-editor's, left alone.
|
||||
/// 3. **Each source is validated before it is touched**: must exist and be a regular file —
|
||||
/// not a directory, which is out of this call's scope entirely — refused `.unreadable`
|
||||
/// naming the source *before* any copy for that file is attempted, so a batch never
|
||||
/// partially copies something it was about to refuse.
|
||||
/// 4. **The collision-free name is decided, then copied to directly** — no temp name and
|
||||
/// rename, unlike `atomicReplace`: `FileManager.copyItem` lands the bytes straight under
|
||||
/// the final name. `copyItem` is not atomic, so on any failure the partial destination is
|
||||
/// removed best-effort and `.io` is thrown naming the source file — "no half-copied
|
||||
/// attachment is ever left in `attachments/`" (02-architecture.md § Write-failure
|
||||
/// surfacing) is a promise about the *failure path*, not a stronger atomicity claim
|
||||
/// `copyItem` cannot make. A crash mid-copy (process death, not a caught `Error`) can
|
||||
/// still leave a partial file on disk — the accepted limit of a non-atomic copy, the same
|
||||
/// one an ordinary Finder copy has.
|
||||
public static func importAttachments(
|
||||
_ sourceURLs: [URL],
|
||||
intoCard cardFolder: URL
|
||||
) throws(BoardWriteError) -> [ImportedAttachment] {
|
||||
let operation = "import attachment"
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
try checkIsUUIDShaped(cardFolder, operation: operation)
|
||||
|
||||
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
||||
do {
|
||||
try FileManager.default.createDirectory(at: attachmentsFolder, withIntermediateDirectories: true)
|
||||
} catch {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: cardFolder.path,
|
||||
reason: .io(message: "could not create attachments folder: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
|
||||
var landed: [ImportedAttachment] = []
|
||||
for sourceURL in sourceURLs {
|
||||
var isDirectory: ObjCBool = false
|
||||
guard FileManager.default.fileExists(atPath: sourceURL.path, isDirectory: &isDirectory) else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: sourceURL.path,
|
||||
reason: .unreadable(message: "file does not exist")
|
||||
)
|
||||
}
|
||||
guard !isDirectory.boolValue else {
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: sourceURL.path,
|
||||
reason: .unreadable(message: "is a folder, not a file — importing a folder is not supported")
|
||||
)
|
||||
}
|
||||
|
||||
let name = freshAttachmentName(for: sourceURL.lastPathComponent, in: attachmentsFolder)
|
||||
let destinationURL = attachmentsFolder.appendingPathComponent(name)
|
||||
do {
|
||||
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: destinationURL)
|
||||
throw BoardWriteError(
|
||||
operation: operation,
|
||||
path: sourceURL.path,
|
||||
reason: .io(message: "could not copy file: \(error.localizedDescription)")
|
||||
)
|
||||
}
|
||||
landed.append(ImportedAttachment(sourceURL: sourceURL, fileName: name))
|
||||
}
|
||||
return landed
|
||||
}
|
||||
|
||||
/// The Finder-style collision-free name for `originalName` landing in `folder`: the name
|
||||
/// itself when nothing on disk claims it yet, else the base name suffixed `" 2"`, `" 3"`, …
|
||||
/// — counting up from 2 against what is on disk *at decision time*, one collision at a time.
|
||||
///
|
||||
/// Splits `originalName` the way `URL` itself does (`deletingPathExtension`/
|
||||
/// `pathExtension`), which is also exactly how Finder does it: an extension-less name
|
||||
/// suffixes directly (`"notes"` → `"notes 2"`), and a multi-dot name splits after the
|
||||
/// *last* dot (`"archive.tar.gz"` → `"archive.tar 2.gz"`, not `"archive 2.tar.gz"`) — both
|
||||
/// accepted as what Finder itself produces, not worked around.
|
||||
///
|
||||
/// `fileExists` is the one test, and it is true for a directory as much as a file — a
|
||||
/// same-named *subfolder* blocks the name exactly like a file would, so an import never
|
||||
/// overwrites, renames, or descends into one; it just renames the incoming file instead.
|
||||
private static func freshAttachmentName(for originalName: String, in folder: URL) -> String {
|
||||
guard FileManager.default.fileExists(atPath: folder.appendingPathComponent(originalName).path) else {
|
||||
return originalName
|
||||
}
|
||||
|
||||
let nameURL = URL(fileURLWithPath: originalName)
|
||||
let base = nameURL.deletingPathExtension().lastPathComponent
|
||||
let ext = nameURL.pathExtension
|
||||
|
||||
var counter = 2
|
||||
while true {
|
||||
let candidate = ext.isEmpty ? "\(base) \(counter)" : "\(base) \(counter).\(ext)"
|
||||
guard FileManager.default.fileExists(atPath: folder.appendingPathComponent(candidate).path) else {
|
||||
return candidate
|
||||
}
|
||||
counter += 1
|
||||
}
|
||||
}
|
||||
|
||||
/// The card's flat attachment listing (01-storage-format.md § Attachments, "the app's
|
||||
/// attachment surfaces … are flat: top-level files only"): the top-level *files* of
|
||||
/// `attachments/`, subfolders and hidden files excluded, symlinks excluded, sorted
|
||||
/// `localizedStandardCompare` — Finder order, so `"shot 2.png"` sorts before `"shot
|
||||
/// 10.png"` rather than after it.
|
||||
///
|
||||
/// A **missing** `attachments/` lists as `[]`, not an error: nothing has been imported yet
|
||||
/// is an ordinary state, not a malformed one. `cardFolder` itself missing, or not a
|
||||
/// directory, *is* `.unreadable` — that is a caller asking about a card that isn't there,
|
||||
/// not an empty listing. Purely a read: nothing here ever creates `attachments/` or
|
||||
/// disturbs anything inside it, subfolders included.
|
||||
public static func listAttachments(ofCard cardFolder: URL) throws(BoardWriteError) -> [String] {
|
||||
let operation = "list attachments"
|
||||
try checkIsDirectory(cardFolder, describedAs: "card folder", operation: operation)
|
||||
|
||||
let attachmentsFolder = cardFolder.appendingPathComponent(attachmentsFolderName, isDirectory: true)
|
||||
guard let entries = try? FileManager.default.contentsOfDirectory(
|
||||
at: attachmentsFolder,
|
||||
includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
) else {
|
||||
return []
|
||||
}
|
||||
|
||||
let files = entries.filter { url in
|
||||
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) else {
|
||||
return false
|
||||
}
|
||||
return values.isRegularFile == true && values.isSymbolicLink != true
|
||||
}
|
||||
return files.map(\.lastPathComponent).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
// MARK: - Move/copy pre-flight
|
||||
|
||||
/// The rank a moved or copied root lands on: the caller's explicit value — a drop between
|
||||
@@ -941,6 +1100,18 @@ public enum CopyStamps: Sendable {
|
||||
case born
|
||||
}
|
||||
|
||||
// MARK: - Attachment vocabulary
|
||||
|
||||
/// One imported attachment: where it came from and the name it landed under. `fileName` is the
|
||||
/// post-collision-rename name — `sourceURL.lastPathComponent` when nothing on disk claimed it,
|
||||
/// the Finder-style `"… 2"` (or higher) variant otherwise (`importAttachments`). `sourceURL` is
|
||||
/// carried through unchanged so a caller can report the batch (which files came from where)
|
||||
/// without re-deriving it from input order.
|
||||
public struct ImportedAttachment: Sendable, Equatable {
|
||||
public let sourceURL: URL
|
||||
public let fileName: String
|
||||
}
|
||||
|
||||
// MARK: - Error
|
||||
|
||||
/// A write that did not happen, said out loud: which operation, which file, and why —
|
||||
|
||||
Reference in New Issue
Block a user