A no-Xcode AppKit app (swiftc + build.sh): - Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find, live Markdown preview (split mode), selection-aware Markdown toolbar. - Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape, US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel), freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich status bar. The Editor follows the selected page; layout persists to a foo.txt.board sidecar; the .txt stays pure plaintext. - scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain). - HTML scraping pipeline: custom selector engine + Discogs profile, ingests to Postgres (psql) into a marketplace_snapshots schema. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
557 lines
25 KiB
Swift
557 lines
25 KiB
Swift
import AppKit
|
|
|
|
final class Document: NSDocument, NSTextViewDelegate {
|
|
private var text = ""
|
|
private weak var textView: NSTextView?
|
|
private(set) var workspace: WorkspaceController?
|
|
// Carries "the user picked Sync Now" intent across the save that a dirty
|
|
// or untitled document needs before it can sync.
|
|
private var pendingManualSync = false
|
|
// Lets a newer sync's status win over a slower, older one's completion.
|
|
private var syncGeneration = 0
|
|
|
|
override class var autosavesInPlace: Bool { false }
|
|
|
|
// MARK: - Window
|
|
|
|
override func makeWindowControllers() {
|
|
let scrollView = NSTextView.scrollableTextView()
|
|
guard let tv = scrollView.documentView as? NSTextView else { return }
|
|
configure(textView: tv)
|
|
tv.string = text
|
|
textView = tv
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 900, height: 620),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
window.minSize = NSSize(width: 480, height: 320)
|
|
window.center()
|
|
|
|
let workspace = WorkspaceController(document: self, editorScrollView: scrollView, textView: tv)
|
|
self.workspace = workspace
|
|
window.contentView = workspace.containerView
|
|
workspace.installToolbar(on: window)
|
|
|
|
let controller = NSWindowController(window: window)
|
|
controller.shouldCascadeWindows = true
|
|
addWindowController(controller)
|
|
loadBoardSidecar()
|
|
window.makeFirstResponder(tv)
|
|
}
|
|
|
|
private func configure(textView tv: NSTextView) {
|
|
TextViewConfig.harden(tv)
|
|
tv.usesFindBar = true
|
|
tv.isIncrementalSearchingEnabled = true
|
|
tv.font = AppSettings.editorFont
|
|
tv.textContainerInset = NSSize(width: 8, height: 10)
|
|
tv.delegate = self
|
|
}
|
|
|
|
// Route the text view's undo stack through the document so the
|
|
// dirty dot and change count stay accurate automatically.
|
|
func undoManager(for view: NSTextView) -> UndoManager? {
|
|
undoManager
|
|
}
|
|
|
|
// MARK: - Reading / writing
|
|
|
|
override func data(ofType typeName: String) throws -> Data {
|
|
if let tv = textView {
|
|
text = tv.string
|
|
}
|
|
guard let data = text.data(using: .utf8) else {
|
|
throw NSError(domain: NSCocoaErrorDomain,
|
|
code: NSFileWriteInapplicableStringEncodingError,
|
|
userInfo: [NSLocalizedDescriptionKey: "Couldn't encode the text as UTF-8."])
|
|
}
|
|
return data
|
|
}
|
|
|
|
override func read(from data: Data, ofType typeName: String) throws {
|
|
if let utf8 = String(data: data, encoding: .utf8) {
|
|
text = utf8
|
|
} else {
|
|
var converted: NSString?
|
|
var lossy: ObjCBool = false
|
|
let encoding = NSString.stringEncoding(for: data,
|
|
encodingOptions: nil,
|
|
convertedString: &converted,
|
|
usedLossyConversion: &lossy)
|
|
// Refuse lossy conversions: saving would silently destroy bytes.
|
|
guard encoding != 0, let converted, !lossy.boolValue else {
|
|
throw NSError(domain: NSCocoaErrorDomain,
|
|
code: NSFileReadInapplicableStringEncodingError,
|
|
userInfo: [NSLocalizedDescriptionKey: "Couldn't determine the file's text encoding."])
|
|
}
|
|
text = converted as String
|
|
}
|
|
textView?.string = text
|
|
workspace?.refreshTextMetrics() // keep the status bar counts current on Revert
|
|
}
|
|
|
|
// MARK: - Save hook → sync
|
|
|
|
override func save(to url: URL,
|
|
ofType typeName: String,
|
|
for saveOperation: NSDocument.SaveOperationType,
|
|
completionHandler: @escaping (Error?) -> Void) {
|
|
textView?.breakUndoCoalescing()
|
|
super.save(to: url, ofType: typeName, for: saveOperation) { [weak self] error in
|
|
completionHandler(error)
|
|
guard let self else { return }
|
|
let manual = self.pendingManualSync
|
|
self.pendingManualSync = false
|
|
guard error == nil,
|
|
saveOperation == .saveOperation || saveOperation == .saveAsOperation else { return }
|
|
self.saveBoardSidecar()
|
|
self.syncAfterSave(url: url, manual: manual)
|
|
self.autoIngestAfterSave()
|
|
}
|
|
}
|
|
|
|
@objc func syncNow(_ sender: Any?) {
|
|
if let url = fileURL, !isDocumentEdited {
|
|
syncAfterSave(url: url, manual: true)
|
|
return
|
|
}
|
|
// Dirty or untitled: save first; the save hook picks up the manual flag.
|
|
pendingManualSync = true
|
|
save(withDelegate: self,
|
|
didSave: #selector(document(_:didSave:contextInfo:)),
|
|
contextInfo: nil)
|
|
}
|
|
|
|
// Clears the manual flag when the user cancels the Save panel (the save
|
|
// override never runs in that case, so the flag would leak into the next save).
|
|
@objc private func document(_ document: NSDocument, didSave: Bool, contextInfo: UnsafeMutableRawPointer?) {
|
|
if !didSave { pendingManualSync = false }
|
|
}
|
|
|
|
private func syncAfterSave(url: URL, manual: Bool) {
|
|
guard SyncSettings.enabled || manual else {
|
|
syncStatus = "" // sync is off — don't leave a stale "Synced ✓"
|
|
refreshSubtitle()
|
|
return
|
|
}
|
|
guard SyncSettings.isConfigured else {
|
|
if manual {
|
|
presentSyncError("No remote configured. Set one in TextPlus → Settings…")
|
|
} else {
|
|
syncStatus = "Sync is on, but no remote is configured — see Settings"
|
|
refreshSubtitle()
|
|
}
|
|
return
|
|
}
|
|
let remoteHost = SyncSettings.remote
|
|
syncGeneration += 1
|
|
let generation = syncGeneration
|
|
syncStatus = "Syncing to \(remoteHost)…"
|
|
refreshSubtitle()
|
|
SyncEngine.upload(fileURL: url) { [weak self] result in
|
|
guard let self, generation == self.syncGeneration else { return }
|
|
switch result {
|
|
case .success:
|
|
let time = Date().formatted(date: .omitted, time: .shortened)
|
|
self.syncStatus = "Synced to \(remoteHost) ✓ \(time)"
|
|
case .failure(let error):
|
|
let firstLine = error.message.split(separator: "\n").first.map(String.init) ?? "unknown error"
|
|
self.syncStatus = "Sync failed — \(firstLine)"
|
|
NSApp.requestUserAttention(.informationalRequest)
|
|
if manual { self.presentSyncError(error.message) }
|
|
}
|
|
self.refreshSubtitle()
|
|
}
|
|
}
|
|
|
|
private var syncStatus = ""
|
|
private var dbStatus = ""
|
|
private var dbGeneration = 0
|
|
private var ingestInFlight = false
|
|
|
|
private func refreshSubtitle() {
|
|
let subtitle = [syncStatus, dbStatus].filter { !$0.isEmpty }.joined(separator: " · ")
|
|
windowControllers.first?.window?.subtitle = subtitle
|
|
}
|
|
|
|
private func presentSyncError(_ message: String) {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Sync Failed"
|
|
alert.informativeText = message
|
|
alert.alertStyle = .warning
|
|
if let window = windowControllers.first?.window {
|
|
alert.beginSheetModal(for: window)
|
|
} else {
|
|
alert.runModal()
|
|
}
|
|
}
|
|
|
|
// MARK: - HTML parsing tools
|
|
|
|
private var currentText: String {
|
|
textView?.string ?? text
|
|
}
|
|
|
|
private static let lastSelectorKey = "lastExtractSelector"
|
|
|
|
@objc func listSelectors(_ sender: Any?) {
|
|
let data = HTMLScanner.summarize(html: currentText)
|
|
Document.openResults(HTMLScanner.formatSummary(data, sourceName: displayName ?? "document"))
|
|
}
|
|
|
|
@objc func extractListings(_ sender: Any?) {
|
|
let page = PageDetector.detect(in: currentText) ?? DetectedPage(url: nil, html: currentText)
|
|
let profile = ScrapeProfile.matching(url: page.url) ?? ScrapeProfile.discogsMarketplace
|
|
guard let extraction = HTMLScanner.extractRows(html: page.html,
|
|
rowSelector: profile.rowSelector,
|
|
fields: profile.fields),
|
|
!extraction.rows.isEmpty else {
|
|
presentSimpleAlert(title: "No Listings Found",
|
|
message: "No \(profile.rowSelector) rows in this document. This works on Discogs marketplace pages (sell/list or a seller's profile).")
|
|
return
|
|
}
|
|
var out = extraction.header.joined(separator: "\t") + "\n"
|
|
out += extraction.rows.map { $0.joined(separator: "\t") }.joined(separator: "\n") + "\n"
|
|
Document.openResults(out)
|
|
}
|
|
|
|
@objc func sendToPostgres(_ sender: Any?) {
|
|
guard let page = PageDetector.detect(in: currentText) else {
|
|
presentSimpleAlert(title: "Not an HTML Page",
|
|
message: "Paste inspector HTML — optionally with the page URL as the first line — and try again.")
|
|
return
|
|
}
|
|
runIngest(page: page, manual: true)
|
|
}
|
|
|
|
private func autoIngestAfterSave() {
|
|
guard PGSettings.autoIngest, let page = PageDetector.detect(in: currentText) else {
|
|
dbStatus = ""
|
|
refreshSubtitle()
|
|
return
|
|
}
|
|
runIngest(page: page, manual: false)
|
|
}
|
|
|
|
private func runIngest(page: DetectedPage, manual: Bool) {
|
|
// One ingest per document at a time: a second overlapping ingest of the
|
|
// same page would bypass the content-hash dedup (its check runs before
|
|
// the first commits) and double-insert audit rows.
|
|
if ingestInFlight {
|
|
if manual {
|
|
presentSimpleAlert(title: "Ingest In Progress",
|
|
message: "This document is already being sent to Postgres. Try again once it finishes.")
|
|
}
|
|
return
|
|
}
|
|
ingestInFlight = true
|
|
dbGeneration += 1
|
|
let generation = dbGeneration
|
|
dbStatus = "DB: parsing…"
|
|
refreshSubtitle()
|
|
let sourceName = displayName ?? "document"
|
|
|
|
func finish(_ status: String, alert: String? = nil) {
|
|
ingestInFlight = false
|
|
guard generation == dbGeneration else { return }
|
|
dbStatus = status
|
|
refreshSubtitle()
|
|
if let alert { presentSimpleAlert(title: "Postgres Ingest Failed", message: alert) }
|
|
}
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
let summary = HTMLScanner.summarize(html: page.html)
|
|
let profile = ScrapeProfile.matching(url: page.url)
|
|
var header: [String] = []
|
|
var rows: [[String]] = []
|
|
if let profile,
|
|
let extraction = HTMLScanner.extractRows(html: page.html,
|
|
rowSelector: profile.rowSelector,
|
|
fields: profile.fields) {
|
|
header = extraction.header
|
|
rows = extraction.rows
|
|
}
|
|
let ingest = PostgresStore.PageIngest(
|
|
url: page.url,
|
|
sourceName: sourceName,
|
|
contentHash: PostgresStore.sha256(page.html),
|
|
elementCount: summary.elementCount,
|
|
selectors: summary.entries,
|
|
profileName: rows.isEmpty ? nil : profile?.name,
|
|
header: header,
|
|
rows: rows
|
|
)
|
|
DispatchQueue.main.async {
|
|
guard let self else { return }
|
|
guard generation == self.dbGeneration else { self.ingestInFlight = false; return }
|
|
self.dbStatus = "DB: ingesting…"
|
|
self.refreshSubtitle()
|
|
// `finish` already holds self for the duration of the ingest.
|
|
PostgresStore.ingest(ingest, force: manual) { result in
|
|
switch result {
|
|
case .success(let report):
|
|
if report.skippedDuplicate {
|
|
finish("DB: unchanged")
|
|
} else {
|
|
var bits = ["\(report.selectorCount) selectors"]
|
|
if report.snapshotCount > 0 { bits.append("\(report.snapshotCount) listings upserted") }
|
|
else if report.rowCount > 0 { bits.append("\(report.rowCount) rows") }
|
|
if report.skippedRows > 0 { bits.append("\(report.skippedRows) skipped") }
|
|
finish("DB ✓ page #\(report.pageId): " + bits.joined(separator: ", "))
|
|
}
|
|
case .failure(let error):
|
|
let firstLine = error.message.split(separator: "\n").first.map(String.init) ?? "error"
|
|
finish("DB failed — \(firstLine)", alert: manual ? error.message : nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func extractForSelector(_ sender: Any?) {
|
|
let alert = NSAlert()
|
|
alert.messageText = "Extract Text for Selector"
|
|
alert.informativeText = "Pulls the text of every matching element into a new document, one match per line (table cells tab-separated). A trailing @attr adds that attribute as the first column; [attr], [!attr], [attr=v], [attr^=v] filter matches.\n\nExamples: .price tr.shortcut_navigable a.item_description_title@href .item_condition span[!class]"
|
|
let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 380, height: 24))
|
|
field.stringValue = UserDefaults.standard.string(forKey: Document.lastSelectorKey) ?? ""
|
|
field.placeholderString = "tag, .class, #id, or a descendant chain"
|
|
alert.accessoryView = field
|
|
alert.addButton(withTitle: "Extract")
|
|
alert.addButton(withTitle: "Cancel")
|
|
alert.window.initialFirstResponder = field
|
|
// The field editor swallows Return before the default button sees it;
|
|
// wire the field's action to the Extract button so Return submits.
|
|
field.target = alert.buttons[0]
|
|
field.action = #selector(NSButton.performClick(_:))
|
|
|
|
let runExtract: () -> Void = { [weak self] in
|
|
guard let self else { return }
|
|
let selector = field.stringValue.trimmingCharacters(in: .whitespaces)
|
|
guard !selector.isEmpty else { return }
|
|
UserDefaults.standard.set(selector, forKey: Document.lastSelectorKey)
|
|
guard let result = HTMLScanner.extractText(html: self.currentText,
|
|
selector: selector,
|
|
sourceName: self.displayName ?? "document") else {
|
|
self.presentSimpleAlert(title: "Bad Selector",
|
|
message: "Couldn't parse \"\(selector)\". Use tag, .class, #id, combos like tr.shortcut_navigable, or a space-separated descendant chain.")
|
|
return
|
|
}
|
|
guard result.matchCount > 0 else {
|
|
self.presentSimpleAlert(title: "No Matches",
|
|
message: "Nothing matched \"\(selector)\". Run Tools → List Div & Class Selectors to see what's in this page.")
|
|
return
|
|
}
|
|
Document.openResults(result.body)
|
|
}
|
|
|
|
if let window = windowControllers.first?.window {
|
|
alert.beginSheetModal(for: window) { response in
|
|
guard response == .alertFirstButtonReturn else { return }
|
|
runExtract()
|
|
}
|
|
} else if alert.runModal() == .alertFirstButtonReturn {
|
|
runExtract()
|
|
}
|
|
}
|
|
|
|
private static func openResults(_ content: String) {
|
|
guard let doc = (try? NSDocumentController.shared.openUntitledDocumentAndDisplay(true)) as? Document else { return }
|
|
doc.setContent(content)
|
|
}
|
|
|
|
private func setContent(_ s: String) {
|
|
text = s
|
|
textView?.string = s
|
|
workspace?.applyAppearance() // theme the freshly inserted results
|
|
workspace?.refreshTextMetrics()
|
|
updateChangeCount(.changeDone)
|
|
}
|
|
|
|
private func presentSimpleAlert(title: String, message: String) {
|
|
let alert = NSAlert()
|
|
alert.messageText = title
|
|
alert.informativeText = message
|
|
if let window = windowControllers.first?.window {
|
|
alert.beginSheetModal(for: window)
|
|
} else {
|
|
alert.runModal()
|
|
}
|
|
}
|
|
|
|
// MARK: - Font size (broadcast to every open window via AppSettings)
|
|
|
|
@objc func makeTextBigger(_ sender: Any?) {
|
|
AppSettings.set(AppSettings.fontSizeKey, Double(min(48, AppSettings.fontSize + 1)))
|
|
}
|
|
|
|
@objc func makeTextSmaller(_ sender: Any?) {
|
|
AppSettings.set(AppSettings.fontSizeKey, Double(max(8, AppSettings.fontSize - 1)))
|
|
}
|
|
|
|
// MARK: - View mode (menu) + appearance toggles
|
|
|
|
@objc func showEditorMode(_ sender: Any?) { workspace?.setMode(.editor) }
|
|
@objc func showSplitMode(_ sender: Any?) { workspace?.setMode(.split) }
|
|
@objc func showBoardMode(_ sender: Any?) { workspace?.setMode(.board) }
|
|
|
|
@objc func toggleSoftWrap(_ sender: Any?) {
|
|
AppSettings.set(AppSettings.softWrapKey, !AppSettings.softWrap)
|
|
}
|
|
|
|
@objc func toggleLineNumbers(_ sender: Any?) {
|
|
AppSettings.set(AppSettings.lineNumbersKey, !AppSettings.lineNumbers)
|
|
}
|
|
|
|
@objc func cycleTheme(_ sender: Any?) {
|
|
let all = EditorTheme.all
|
|
let idx = all.firstIndex { $0.id == AppSettings.theme.id } ?? 0
|
|
AppSettings.set(AppSettings.themeKey, all[(idx + 1) % all.count].id)
|
|
}
|
|
|
|
// MARK: - Markdown markup (selection-aware)
|
|
|
|
@objc func markupBold(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "**", suffix: "**", placeholder: "bold") } }
|
|
@objc func markupItalic(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "*", suffix: "*", placeholder: "italic") } }
|
|
@objc func markupCode(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "`", suffix: "`", placeholder: "code") } }
|
|
@objc func markupStrikethrough(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "~~", suffix: "~~", placeholder: "strike") } }
|
|
@objc func markupHeading(_ sender: Any?) { withTextView { TextMarkup.linePrefix($0, prefix: "# ") } }
|
|
@objc func markupList(_ sender: Any?) { withTextView { TextMarkup.linePrefix($0, prefix: "- ") } }
|
|
@objc func markupQuote(_ sender: Any?) { withTextView { TextMarkup.linePrefix($0, prefix: "> ") } }
|
|
@objc func markupLink(_ sender: Any?) { withTextView { TextMarkup.wrap($0, prefix: "[", suffix: "](https://)", placeholder: "label") } }
|
|
|
|
private func withTextView(_ body: (NSTextView) -> Void) {
|
|
guard let textView else { return }
|
|
body(textView)
|
|
}
|
|
|
|
// MARK: - Board sidecar (foo.txt.board/) + image import
|
|
|
|
/// Bundle directory next to the document: foo.txt → foo.txt.board/
|
|
private func boardSidecarDir() -> URL? {
|
|
fileURL?.appendingPathExtension("board")
|
|
}
|
|
|
|
private var legacyCanvasURL: URL? {
|
|
fileURL?.appendingPathExtension("canvas.json")
|
|
}
|
|
|
|
/// Build the board model: from the sidecar if present, else migrate a legacy
|
|
/// canvas sidecar's shapes, else a fresh default board. Never throws.
|
|
func loadBoardSidecar() {
|
|
guard let board = workspace?.boardContainer.boardView else { return }
|
|
var doc: BoardDoc?
|
|
if let dir = boardSidecarDir(),
|
|
let data = try? Data(contentsOf: dir.appendingPathComponent("board.json")),
|
|
let decoded = try? JSONDecoder().decode(BoardDoc.self, from: data) {
|
|
doc = decoded
|
|
} else if let legacy = legacyCanvasURL,
|
|
let data = try? Data(contentsOf: legacy),
|
|
let canvasDoc = try? JSONDecoder().decode(DrawCanvasView.CanvasDoc.self, from: data) {
|
|
// One-way migration of the old paint canvas into the board shapes layer.
|
|
var fresh = BoardDoc.makeDefault(worldSize: BoardView.worldSize)
|
|
fresh.shapes = canvasDoc
|
|
doc = fresh
|
|
}
|
|
let finalDoc = doc ?? BoardDoc.makeDefault(worldSize: BoardView.worldSize)
|
|
board.load(doc: finalDoc)
|
|
workspace?.boardContainer.refreshPageControls()
|
|
workspace?.bindInitialEditorPage() // main card owns the document text view
|
|
// Load image assets by card id.
|
|
if let dir = boardSidecarDir() {
|
|
for card in finalDoc.cards where card.kind == .image {
|
|
let name = card.imageFile ?? "img-\(card.id).png"
|
|
if let img = NSImage(contentsOf: dir.appendingPathComponent(name)) {
|
|
board.setImage(img, forCardID: card.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveBoardSidecar() {
|
|
guard let board = workspace?.boardContainer.boardView, let dir = boardSidecarDir() else { return }
|
|
let doc = board.snapshotDoc
|
|
let fm = FileManager.default
|
|
// Don't create a sidecar for an untouched plaintext doc, but ALWAYS keep
|
|
// an existing one current — otherwise clearing a board back to empty
|
|
// would leave a stale board.json that resurrects the deleted objects.
|
|
let used = doc.cards.count > 1 || !doc.shapes.elements.isEmpty
|
|
|| !(board.mainCardView?.textView?.string.isEmpty ?? true)
|
|
guard used || fm.fileExists(atPath: dir.path) else { return }
|
|
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
|
|
if let data = try? JSONEncoder.boardEncoder.encode(doc) {
|
|
let tmp = dir.appendingPathComponent("board.json.tmp")
|
|
let dest = dir.appendingPathComponent("board.json")
|
|
if (try? data.write(to: tmp)) != nil {
|
|
_ = try? fm.replaceItemAt(dest, withItemAt: tmp)
|
|
}
|
|
}
|
|
// Write image assets; collect kept filenames for orphan GC.
|
|
var kept = Set(["board.json", "preview.png"])
|
|
for card in doc.cards where card.kind == .image {
|
|
let name = card.imageFile ?? "img-\(card.id).png"
|
|
kept.insert(name)
|
|
if let img = board.image(forCardID: card.id),
|
|
let png = pngData(from: img) {
|
|
try? png.write(to: dir.appendingPathComponent(name), options: .atomic)
|
|
}
|
|
}
|
|
if let preview = workspace?.boardContainer.boardPNG() {
|
|
try? preview.write(to: dir.appendingPathComponent("preview.png"), options: .atomic)
|
|
}
|
|
// GC orphaned image files for this document's bundle only.
|
|
if let names = try? fm.contentsOfDirectory(atPath: dir.path) {
|
|
for n in names where n.hasPrefix("img-") && !kept.contains(n) {
|
|
try? fm.removeItem(at: dir.appendingPathComponent(n))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Import an image file as a new board card (re-encoded to PNG on save).
|
|
func importImageToBoard(url: URL, at center: NSPoint) {
|
|
guard let image = NSImage(contentsOf: url), let board = workspace?.boardContainer.boardView else { return }
|
|
board.addImageCard(image, at: center)
|
|
}
|
|
|
|
func importImageToBoard(_ image: NSImage, at center: NSPoint) {
|
|
workspace?.boardContainer.boardView.addImageCard(image, at: center)
|
|
}
|
|
|
|
private func pngData(from image: NSImage) -> Data? {
|
|
guard let tiff = image.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff) else { return nil }
|
|
return rep.representation(using: .png, properties: [:])
|
|
}
|
|
|
|
/// Write the PNG to a temp file and reuse the scp sync engine to push it to
|
|
/// the configured tailnet Mac — same destination as text files.
|
|
func sendSketchToTailnet(pngData: Data) {
|
|
guard SyncSettings.isConfigured else {
|
|
workspace?.setStatus("Set a tailnet remote in Settings first")
|
|
return
|
|
}
|
|
let base = (displayName ?? "sketch").replacingOccurrences(of: " ", with: "_")
|
|
let tmp = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("\(base)-sketch.png")
|
|
do {
|
|
try pngData.write(to: tmp)
|
|
} catch {
|
|
workspace?.setStatus("Couldn't stage PNG: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
workspace?.setStatus("Sending sketch to \(SyncSettings.remote)…")
|
|
SyncEngine.upload(fileURL: tmp) { [weak self] result in
|
|
switch result {
|
|
case .success:
|
|
self?.workspace?.setStatus("Sketch sent to \(SyncSettings.remote) ✓")
|
|
case .failure(let error):
|
|
let line = error.message.split(separator: "\n").first.map(String.init) ?? "failed"
|
|
self?.workspace?.setStatus("Sketch sync failed — \(line)")
|
|
}
|
|
try? FileManager.default.removeItem(at: tmp)
|
|
}
|
|
}
|
|
}
|