textplus/Sources/Document.swift
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
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>
2026-06-14 16:08:59 +10:00

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