Business Logic Model: Unit 4 — Image & Media
Pipeline Overview
Image arrives (drop or paste)
|
v
[1] Input Acquisition
| Drop: extract file URLs from NSDraggingInfo pasteboard
| Paste: extract NSImage from NSPasteboard
v
[2] Document Readiness Check
| Verify document is saved (has fileURL)
| If untitled → prompt user to save first
v
[3] Asset Directory Management
| Resolve {filename}.assets/ path from MarkdownDocument.assetsDirectoryURL
| FileService.createDirectoryIfNeeded()
v
[4] Image Import & Write
| Drop: copy source file to assets directory
| Paste: convert NSImage → PNG data, write to assets directory
| Generate unique filename (timestamp-based)
| FileService.safeWrite()
v
[5] Path Validation
| Validate relative path against SEC-01 rules
| No absolute paths, no ../ traversal, only relative
v
[6] Markdown Insertion
| EditorViewModel.insertImage(at:relativePath:)
| Insert  at cursor/drop position
| Normal parse/render flow picks up the new image block
[1] Input Acquisition
Drag & Drop Path
Trigger: ShoechooTextView.performDragOperation(_:) called by AppKit
Logic:
- Read pasteboard items from
NSDraggingInfo.draggingPasteboard - Extract file URLs using
NSPasteboard.ReadingOptionKey.urlReadingFileURLsOnly - Filter URLs to those whose
pathExtensionmatchesImageFormat.droppable - If no valid image URLs remain, return
false(decline the drop) - Determine drop position: convert drop point to character offset via
characterIndexForInsertion(at:) - Construct
DragDropPayload(fileURLs:, dropLocation:) - Forward to
EditorViewModelfor async processing
Clipboard Paste Path
Trigger: ShoechooTextView.paste(_:) override, when pasteboard contains image data but not text
Logic:
- Check
NSPasteboard.generalfor image types:NSPasteboard.PasteboardType.tiff,.png - If text is also present, prefer text (standard paste behavior) — only intercept when image-only
- Read
NSImage(pasteboard:)from general pasteboard - If image is nil, fall through to default paste behavior
- Construct
PasteboardImageData(image:, sourceFormat: .png, originalUTType:) - Forward to
EditorViewModelat current cursor position
[2] Document Readiness Check
Input: MarkdownDocument (from EditorViewModel)
Logic:
- Check
document.fileURL != nil - If
fileURLis nil (untitled document):- Set
EditorViewModel.pendingImageImportto hold the payload - Trigger save prompt via
EditorViewModel.showSaveBeforeImageInsert = true - When save completes successfully, resume import from
pendingImageImport - If save is cancelled, discard
pendingImageImportand show no error
- Set
- If
fileURLis present, proceed to step [3]
[3] Asset Directory Management
Input: MarkdownDocument.assetsDirectoryURL (computed as {filename}.assets/ sibling to the .md file)
Logic:
- Resolve
assetsDirectoryURLfrom document:documentURL.deletingPathExtension().appendingPathExtension("assets") - Call
FileService.createDirectoryIfNeeded(at: assetsDirectoryURL)- If directory exists: no-op
- If directory does not exist: create with
FileManager.createDirectory(at:withIntermediateDirectories:true) - On failure: throw
ImageImportError.assetsDirectoryCreationFailed
[4] Image Import & Write
Drop Path: ImageService.importDroppedImage(_:to:)
Input: Source file URL, assets directory URL
Logic:
- Validate source file exists and is readable
- Determine
ImageFormatfrom file extension - Generate target filename:
- Use original filename if no conflict exists
- If conflict: append
-1,-2, etc. before extension
- Construct target URL:
assetsDirectoryURL.appendingPathComponent(targetFilename) - Call
FileService.safeWrite()— copy file data to target - Compute file size and image dimensions from the written file
- Return
ImageImportResult(relativePath:, format:, fileSize:, dimensions:)
Paste Path: ImageService.importPastedImage(from:to:)
Input: NSPasteboard, assets directory URL
Logic:
- Read
NSImagefrom pasteboard - Convert to PNG data via
NSBitmapImageRep→representation(using: .png, properties: [:]) - If conversion fails: throw
ImageImportError.imageDataConversionFailed - Generate timestamped filename:
ImageService.generateFilename(for:)→ e.g.,paste-20260402-143012.png - If filename conflicts: append
-1,-2, etc. - Construct target URL:
assetsDirectoryURL.appendingPathComponent(filename) - Call
FileService.safeWrite(data, to: targetURL) - Return
ImageImportResult
Filename Generation: ImageService.generateFilename(for:)
Logic:
- Format:
paste-{yyyyMMdd}-{HHmmss}.png - Use
DateFormatterwithen_US_POSIXlocale, UTC timezone - If a file with that name already exists, append sequential suffix:
paste-20260402-143012-1.png
[5] Path Validation
Input: Relative path string (e.g., MyDoc.assets/screenshot.png)
Logic (SEC-01 compliance):
- Reject if path starts with
/(absolute path) - Reject if path contains
..component (directory traversal) - Reject if path contains URL schemes other than implicit relative (no
http://,https://,ftp://) - Reject if path contains characters outside safe set: alphanumeric,
-,_,.,/ - Validate the path resolves to a location within the document’s parent directory
- On failure: throw
ImageImportError.pathValidationFailed(reason)
[6] Markdown Insertion
Input: position: Int, relativePath: String
Logic (EditorViewModel.insertImage(at:relativePath:)):
- Derive alt text from filename: strip extension, replace
-and_with spaces - Construct Markdown snippet:
 - Determine insertion behavior:
- If position is at the start of a line: insert snippet + newline
- If position is mid-line: insert newline + snippet + newline
- Modify
sourceTextby inserting the snippet at the resolved position - Normal text change flow triggers: parse → diff → re-render
- The new image block renders per BR-02.7 (Unit 1): inline image display when inactive,
syntax when active
FileService Extensions
createDirectoryIfNeeded(at:)
actor FileService {
func createDirectoryIfNeeded(at url: URL) async throws {
let fm = FileManager.default
var isDir: ObjCBool = false
if fm.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue {
return // Already exists
}
try fm.createDirectory(at: url, withIntermediateDirectories: true)
}
}
safeWrite(_:to:)
actor FileService {
func safeWrite(_ data: Data, to url: URL) async throws {
// 1. Write to temporary file in same directory (atomic prerequisite)
let tempURL = url.deletingLastPathComponent()
.appendingPathComponent(UUID().uuidString + ".tmp")
do {
try data.write(to: tempURL, options: .atomic)
// 2. Move temporary file to final destination
let fm = FileManager.default
if fm.fileExists(atPath: url.path) {
try fm.removeItem(at: url)
}
try fm.moveItem(at: tempURL, to: url)
} catch {
// SEC-04: Clean up temporary file on failure
try? FileManager.default.removeItem(at: tempURL)
throw error
}
}
}
Error Handling Strategy (SEC-04)
| Operation | Error | Recovery |
|---|---|---|
| Directory creation | Permission denied, disk full | Show alert, do not insert image |
| File copy/write | I/O error, disk full | Clean up temp files, show alert |
| PNG conversion | Corrupt image data | Show alert “Could not convert image” |
| Path validation | SEC-01 violation | Reject silently (should not occur in normal flow) |
| Untitled document | No fileURL | Prompt save, resume on success |