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) } } }