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>
This commit is contained in:
monster 2026-06-14 16:08:59 +10:00
commit 09972ccde1
23 changed files with 5912 additions and 0 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Build artifact
TextPlus.app/
# Board / canvas sidecars (per-document runtime data)
*.board/
*.canvas.json
*.canvas.png
# macOS
.DS_Store

47
Info.plist Normal file
View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>TextPlus</string>
<key>CFBundleDisplayName</key>
<string>TextPlus</string>
<key>CFBundleExecutable</key>
<string>TextPlus</string>
<key>CFBundleIdentifier</key>
<string>local.textplus</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Plain Text Document</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<!-- plain-text only: claiming public.text/public.data would let a
binary or RTF file be opened and silently rewritten as UTF-8 -->
<key>LSItemContentTypes</key>
<array>
<string>public.plain-text</string>
</array>
<key>NSDocumentClass</key>
<string>TextPlus.Document</string>
</dict>
</array>
</dict>
</plist>

176
README.md Normal file
View File

@ -0,0 +1,176 @@
# TextPlus
A TextEdit-style **plaintext-only** editor for macOS that can automatically
`scp` every file you save to another Mac on your Tailscale network — and that
grew a Markdown preview, themes, an HTML→Postgres scraping pipeline, and a
Freeform-style spatial **board**.
No rich text, no smart quotes, no autocorrect — just text, plus a lot of hooks.
## Three view modes
The toolbar segmented control (top-left) and ⌃⌘1 / ⌃⌘2 / ⌃⌘3 switch between:
- **Editor** — the plain text editor: line-number gutter, 5 themes, soft wrap,
font controls, find.
- **Split** — editor on the left, a live **Markdown preview** on the right.
- **Board** — a Freeform-style infinite, zoomable canvas. Wheel shortcuts:
**⌃-scroll zooms** (centered on the pointer), **⌥-scroll pans horizontally**,
**⌘-scroll pans vertically**; a plain wheel scrolls normally and pinch zooms.
Your document lives here as a **page card** you can place
anywhere; arrange clean **boxes**, drop **images**, and sketch with the pen.
**Double-click or press Return on the page to focus it** — the board zooms in
and the card becomes a normal text editor bound to the document. Click empty
space or press ⎋ to zoom back out.
Board objects:
- **Page card** (the main one) — *is* the `.txt`; editing it edits the document.
Set its size with the **ruler button** (US Letter / A4 / Legal / Square /
Wide 16:9 / Note) and the **P/L** orientation toggle in the palette.
- **Text pages** ("New text page" button) — extra pages created at the current
page size, freely resizable; saved in the sidecar, not the `.txt`.
**Select a text page and switch to the Editor tab to edit it full-screen**
the Editor follows whichever page is selected on the board (the status bar
shows "Board note" when you're editing one of these instead of the main page).
- **Boxes** (Box tool, drag to draw) — clean translucent rectangles for diagrams,
draggable / resizable / grid-snapped.
- **Images** — the Add Image button, paste (⌘V), or drag a file onto the board.
- **Freehand** — pen / marker / line / arrow / oval / eraser, drawn behind the cards.
One color drives the **pen, shapes, and boxes** together — click the color well
for the full picker, or the **eyedropper** to grab a color from anywhere on
screen. **Zoom to Fit** frames everything; the board exports to PNG or sends to
your tailnet like a saved file.
**Right-click anywhere on the board** for a full nested menu — tools, new
page/image, page size & orientation, zoom, color (swatches / screen-pick /
picker), view mode, theme, undo/redo, export/send, plus per-card actions
(edit, bring to front / send to back, duplicate, delete) when you click a card.
Everything is reachable from the mouse.
The **status bar** is mode-aware: in the editor it shows line/column, selection,
words/chars/lines and reading time; in board mode it shows the active tool, page
size/orientation (or the selected card's dimensions), zoom %, and card count.
**Settings → Board** (⌘,) sets the default page size, orientation, and grid
snapping for new documents.
The board layout persists in a sidecar bundle next to the document
(`foo.txt.board/`: `board.json` + image assets + a `preview.png`). The `.txt`
itself stays pure plaintext — open it in any other editor and you just see the
page's prose. Delete the `foo.txt.board/` folder to reset the board.
## Build
```sh
./build.sh
```
Produces `TextPlus.app` in this folder. Drag it to `/Applications` if you want
it in Launchpad/Spotlight, or just `open TextPlus.app`.
Requires only the Xcode Command Line Tools (`xcode-select --install`) — no Xcode.
## Set up sync
1. Open **TextPlus → Settings…** (⌘,)
2. **Remote**: `user@host` of the target Mac. Tailscale MagicDNS names work
(`mini@studio`), as do raw Tailscale IPs (`mini@100.x.y.z`) and any alias
from your `~/.ssh/config` (then you can leave out the `user@` part).
3. **Folder**: the directory on the target Mac to drop files into,
e.g. `/Users/you/Documents/inbox`. Leave empty for the remote home folder.
4. Hit **Test Connection** — it checks both SSH auth and that the folder exists.
5. Turn on **Copy file to remote Mac on every save**.
Now every ⌘S also fires an `scp` in the background. The window subtitle shows
`Syncing… → Synced ✓ 10:42` or the error if it failed. **File → Sync Now**
(⌥⌘S) re-pushes the current file any time, even with the toggle off.
### One-time SSH key setup (if `ssh user@host` still asks for a password)
On the target Mac: **System Settings → General → Sharing → Remote Login** on.
On this Mac:
```sh
ssh-keygen -t ed25519 # skip if ~/.ssh/id_ed25519 already exists
ssh-copy-id user@host # copies your key to the target Mac
ssh user@host # should now log in with no password prompt
```
TextPlus runs scp with `BatchMode=yes`, so it will never pop a password
prompt — if key auth isn't working, the sync just fails with the error shown
in the window subtitle.
## HTML scraping tools
Paste `<body>` HTML copied from the browser inspector into a document, then:
- **Tools → List Div & Class Selectors** (⌥⌘L) — opens a new document listing
every unique selector (`tag#id.class`) with a count and a sample of its text,
sorted by count so repeating listing rows float to the top.
- **Tools → Extract Text for Selector…** (⌥⌘E) — type a selector and get every
match's text in a new document, one match per line, table cells tab-separated.
Supports `tag`, `.class`, `#id`, combos (`tr.shortcut_navigable`), and
space-separated descendant chains (`table.table_block td`). No child
combinator (`>`) — use a space instead.
Selector extras (work in the Extract dialog and in scrape profiles):
- `@attr` at the end pulls an attribute instead of text, as the first column:
`a.item_description_title@href`, `span.price@data-pricevalue`.
- `[attr]` requires the attribute, `[!attr]` requires its absence,
`[attr=value]` exact match, `[attr^=value]` prefix:
`.item_condition span[!class]`, `a[href^=/seller/]`.
Workflow: paste → ⌥⌘L → spot the selector with the right count/sample → ⌥⌘E.
## Postgres ingest (the scraper pipeline)
This turns the editor into the front end of your Discogs snapshot database.
Copy a marketplace page's `<body>` from the inspector, paste it into a
document with **the page URL on the first line**, and either:
- **Tools → Send Page to Postgres** (⌥⌘P) — parse and store right now, or
- turn on **auto-ingest** in Settings so every save stores automatically.
What gets stored (tables auto-created on first connect):
| Table | Contents |
|---|---|
| `tp_pages` | one row per captured page (url, source, content hash, counts, timestamp) |
| `tp_selectors` | the full selector summary for that page |
| `tp_rows` | every extracted row as JSONB, keyed to the page |
| `discogs_sellers` | `seller_id`, username — upserted by `data-seller-id` |
| `marketplace_snapshots` | one row per listing, upserted by `data-item-id` |
`marketplace_snapshots` matches the schema from your scraper notes:
`item_id`, `release_id` (your bridge to the local XML dump), `seller_id`,
title, listing URL, media/sleeve condition, currency, `price_value`,
`shipping_value`, and `first_seen` / `last_seen` / `is_active` for tracking
state over time. Re-ingesting a page **upserts**: existing listings get
`last_seen` and price refreshed (`first_seen` preserved); new ones inserted.
Setup: **Settings → Postgres**. The connection URI defaults to
`postgresql://localhost:5433/scraperrr` (port 5433 because a Tailscale ssh
tunnel already owns 5432 on this Mac). Hit **Test & Create Tables**, then flip
auto-ingest if you want it. Ingest shells out to `psql`
(`brew install postgresql@17`); it does **not** fetch pages — you paste the
DOM, which sidesteps Cloudflare entirely, exactly as your notes planned.
Recognized pages (the `discogs_marketplace` profile): `sell/list`,
`sell/mywants`, and `seller/<name>/profile`. Any other HTML still gets its
page + selector summary stored; only the structured listing tables are
Discogs-specific.
## Notes
- Sync fires only on explicit saves (⌘S / Save As), never on autosave —
half-typed files don't get pushed.
- Files are written as UTF-8. Opening files in other encodings works
(encoding is auto-detected); they're converted to UTF-8 on save.
- `StrictHostKeyChecking=accept-new` is used: first connection to a new host
is trusted automatically (fine on a tailnet), but a *changed* host key is
still rejected.
- Settings live in `defaults read local.textplus`.

BIN
Resources/AppIcon.icns Normal file

Binary file not shown.

262
Sources/AppDelegate.swift Normal file
View File

@ -0,0 +1,262 @@
import AppKit
import SwiftUI
final class AppDelegate: NSObject, NSApplicationDelegate {
private var settingsWindow: NSWindow?
func applicationWillFinishLaunching(_ notification: Notification) {
NSApp.mainMenu = buildMainMenu()
}
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.activate(ignoringOtherApps: true)
}
func applicationShouldOpenUntitledFile(_ sender: NSApplication) -> Bool {
true
}
// Hold termination until in-flight scp uploads finish (bounded), so a
// Cmd-Q-then-save sync isn't abandoned or its failure swallowed.
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
guard SyncEngine.inFlightUploads > 0 else { return .terminateNow }
SyncEngine.onIdle = {
SyncEngine.onIdle = nil
NSApp.reply(toApplicationShouldTerminate: true)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 20) {
if SyncEngine.onIdle != nil {
SyncEngine.onIdle = nil
NSApp.reply(toApplicationShouldTerminate: true)
}
}
return .terminateLater
}
@objc func showSettings(_ sender: Any?) {
if settingsWindow == nil {
let host = NSHostingController(rootView: SettingsView())
let window = NSWindow(contentViewController: host)
window.title = "TextPlus Settings"
window.styleMask.remove([.resizable, .miniaturizable])
window.isReleasedWhenClosed = false
window.center()
settingsWindow = window
}
settingsWindow?.makeKeyAndOrderFront(nil)
NSApp.activate(ignoringOtherApps: true)
}
// MARK: - Menu
private func buildMainMenu() -> NSMenu {
let mainMenu = NSMenu()
// App menu
let appMenuItem = NSMenuItem()
mainMenu.addItem(appMenuItem)
let appMenu = NSMenu()
appMenuItem.submenu = appMenu
appMenu.addItem(withTitle: "About TextPlus",
action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)),
keyEquivalent: "")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Settings…",
action: #selector(AppDelegate.showSettings(_:)),
keyEquivalent: ",")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Hide TextPlus",
action: #selector(NSApplication.hide(_:)),
keyEquivalent: "h")
let hideOthers = appMenu.addItem(withTitle: "Hide Others",
action: #selector(NSApplication.hideOtherApplications(_:)),
keyEquivalent: "h")
hideOthers.keyEquivalentModifierMask = [.command, .option]
appMenu.addItem(withTitle: "Show All",
action: #selector(NSApplication.unhideAllApplications(_:)),
keyEquivalent: "")
appMenu.addItem(.separator())
appMenu.addItem(withTitle: "Quit TextPlus",
action: #selector(NSApplication.terminate(_:)),
keyEquivalent: "q")
// File menu
let fileMenuItem = NSMenuItem()
mainMenu.addItem(fileMenuItem)
let fileMenu = NSMenu(title: "File")
fileMenuItem.submenu = fileMenu
fileMenu.addItem(withTitle: "New",
action: #selector(NSDocumentController.newDocument(_:)),
keyEquivalent: "n")
fileMenu.addItem(withTitle: "Open…",
action: #selector(NSDocumentController.openDocument(_:)),
keyEquivalent: "o")
// AppKit auto-inserts a managed "Open Recent" submenu after the
// item whose action is openDocument: don't build one by hand.
fileMenu.addItem(.separator())
fileMenu.addItem(withTitle: "Close",
action: #selector(NSWindow.performClose(_:)),
keyEquivalent: "w")
fileMenu.addItem(withTitle: "Save",
action: #selector(NSDocument.save(_:)),
keyEquivalent: "s")
let saveAs = fileMenu.addItem(withTitle: "Save As…",
action: #selector(NSDocument.saveAs(_:)),
keyEquivalent: "S")
saveAs.keyEquivalentModifierMask = [.command, .shift]
fileMenu.addItem(withTitle: "Revert to Saved",
action: #selector(NSDocument.revertToSaved(_:)),
keyEquivalent: "")
fileMenu.addItem(.separator())
let syncNow = fileMenu.addItem(withTitle: "Sync Now",
action: #selector(Document.syncNow(_:)),
keyEquivalent: "s")
syncNow.keyEquivalentModifierMask = [.command, .option]
// Edit menu
let editMenuItem = NSMenuItem()
mainMenu.addItem(editMenuItem)
let editMenu = NSMenu(title: "Edit")
editMenuItem.submenu = editMenu
let undo = editMenu.addItem(withTitle: "Undo", action: Selector(("undo:")), keyEquivalent: "z")
undo.target = nil
let redo = editMenu.addItem(withTitle: "Redo", action: Selector(("redo:")), keyEquivalent: "Z")
redo.keyEquivalentModifierMask = [.command, .shift]
editMenu.addItem(.separator())
editMenu.addItem(withTitle: "Cut", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
editMenu.addItem(withTitle: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
editMenu.addItem(withTitle: "Paste", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
editMenu.addItem(withTitle: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
editMenu.addItem(.separator())
let findItem = editMenu.addItem(withTitle: "Find…",
action: #selector(NSTextView.performFindPanelAction(_:)),
keyEquivalent: "f")
findItem.tag = Int(NSTextFinder.Action.showFindInterface.rawValue)
let findNext = editMenu.addItem(withTitle: "Find Next",
action: #selector(NSTextView.performFindPanelAction(_:)),
keyEquivalent: "g")
findNext.tag = Int(NSTextFinder.Action.nextMatch.rawValue)
let findPrev = editMenu.addItem(withTitle: "Find Previous",
action: #selector(NSTextView.performFindPanelAction(_:)),
keyEquivalent: "G")
findPrev.keyEquivalentModifierMask = [.command, .shift]
findPrev.tag = Int(NSTextFinder.Action.previousMatch.rawValue)
let useSelection = editMenu.addItem(withTitle: "Use Selection for Find",
action: #selector(NSTextView.performFindPanelAction(_:)),
keyEquivalent: "e")
useSelection.tag = Int(NSTextFinder.Action.setSearchString.rawValue)
// View menu
let viewMenuItem = NSMenuItem()
mainMenu.addItem(viewMenuItem)
let viewMenu = NSMenu(title: "View")
viewMenuItem.submenu = viewMenu
let editorMode = viewMenu.addItem(withTitle: "Editor",
action: #selector(Document.showEditorMode(_:)),
keyEquivalent: "1")
editorMode.keyEquivalentModifierMask = [.command, .control]
let splitMode = viewMenu.addItem(withTitle: "Editor + Preview",
action: #selector(Document.showSplitMode(_:)),
keyEquivalent: "2")
splitMode.keyEquivalentModifierMask = [.command, .control]
let boardMode = viewMenu.addItem(withTitle: "Board",
action: #selector(Document.showBoardMode(_:)),
keyEquivalent: "3")
boardMode.keyEquivalentModifierMask = [.command, .control]
viewMenu.addItem(.separator())
let bigger = viewMenu.addItem(withTitle: "Make Text Bigger",
action: #selector(Document.makeTextBigger(_:)),
keyEquivalent: "+")
bigger.keyEquivalentModifierMask = [.command]
// "+" is a shifted key, so that item only fires on =. This hidden
// alternate makes plain = work too, like TextEdit.
let biggerAlternate = viewMenu.addItem(withTitle: "Make Text Bigger",
action: #selector(Document.makeTextBigger(_:)),
keyEquivalent: "=")
biggerAlternate.keyEquivalentModifierMask = [.command]
biggerAlternate.isAlternate = true
viewMenu.addItem(withTitle: "Make Text Smaller",
action: #selector(Document.makeTextSmaller(_:)),
keyEquivalent: "-")
viewMenu.addItem(.separator())
viewMenu.addItem(withTitle: "Toggle Soft Wrap",
action: #selector(Document.toggleSoftWrap(_:)),
keyEquivalent: "")
viewMenu.addItem(withTitle: "Toggle Line Numbers",
action: #selector(Document.toggleLineNumbers(_:)),
keyEquivalent: "")
let cycleTheme = viewMenu.addItem(withTitle: "Cycle Theme",
action: #selector(Document.cycleTheme(_:)),
keyEquivalent: "t")
cycleTheme.keyEquivalentModifierMask = [.command, .control]
// Format menu (Markdown markup)
let formatMenuItem = NSMenuItem()
mainMenu.addItem(formatMenuItem)
let formatMenu = NSMenu(title: "Format")
formatMenuItem.submenu = formatMenu
let boldItem = formatMenu.addItem(withTitle: "Bold",
action: #selector(Document.markupBold(_:)), keyEquivalent: "b")
boldItem.keyEquivalentModifierMask = [.command]
let italicItem = formatMenu.addItem(withTitle: "Italic",
action: #selector(Document.markupItalic(_:)), keyEquivalent: "i")
italicItem.keyEquivalentModifierMask = [.command]
let codeItem = formatMenu.addItem(withTitle: "Inline Code",
action: #selector(Document.markupCode(_:)), keyEquivalent: "k")
codeItem.keyEquivalentModifierMask = [.command, .shift]
formatMenu.addItem(withTitle: "Strikethrough",
action: #selector(Document.markupStrikethrough(_:)), keyEquivalent: "")
formatMenu.addItem(.separator())
formatMenu.addItem(withTitle: "Heading",
action: #selector(Document.markupHeading(_:)), keyEquivalent: "")
formatMenu.addItem(withTitle: "Bullet List",
action: #selector(Document.markupList(_:)), keyEquivalent: "")
formatMenu.addItem(withTitle: "Block Quote",
action: #selector(Document.markupQuote(_:)), keyEquivalent: "")
let linkItem = formatMenu.addItem(withTitle: "Link",
action: #selector(Document.markupLink(_:)), keyEquivalent: "k")
linkItem.keyEquivalentModifierMask = [.command]
// Tools menu
let toolsMenuItem = NSMenuItem()
mainMenu.addItem(toolsMenuItem)
let toolsMenu = NSMenu(title: "Tools")
toolsMenuItem.submenu = toolsMenu
let listSel = toolsMenu.addItem(withTitle: "List Div & Class Selectors",
action: #selector(Document.listSelectors(_:)),
keyEquivalent: "l")
listSel.keyEquivalentModifierMask = [.command, .option]
let extractSel = toolsMenu.addItem(withTitle: "Extract Text for Selector…",
action: #selector(Document.extractForSelector(_:)),
keyEquivalent: "e")
extractSel.keyEquivalentModifierMask = [.command, .option]
toolsMenu.addItem(.separator())
let listings = toolsMenu.addItem(withTitle: "Extract Discogs Listings",
action: #selector(Document.extractListings(_:)),
keyEquivalent: "d")
listings.keyEquivalentModifierMask = [.command, .option]
let sendPG = toolsMenu.addItem(withTitle: "Send Page to Postgres",
action: #selector(Document.sendToPostgres(_:)),
keyEquivalent: "p")
sendPG.keyEquivalentModifierMask = [.command, .option]
// Window menu
let windowMenuItem = NSMenuItem()
mainMenu.addItem(windowMenuItem)
let windowMenu = NSMenu(title: "Window")
windowMenuItem.submenu = windowMenu
windowMenu.addItem(withTitle: "Minimize",
action: #selector(NSWindow.performMiniaturize(_:)),
keyEquivalent: "m")
windowMenu.addItem(withTitle: "Zoom",
action: #selector(NSWindow.performZoom(_:)),
keyEquivalent: "")
windowMenu.addItem(.separator())
windowMenu.addItem(withTitle: "Bring All to Front",
action: #selector(NSApplication.arrangeInFront(_:)),
keyEquivalent: "")
NSApp.windowsMenu = windowMenu
return mainMenu
}
}

147
Sources/Appearance.swift Normal file
View File

@ -0,0 +1,147 @@
import AppKit
/// App-wide editor appearance: theme, font family, font size, soft-wrap and
/// line-number toggles. Persisted in UserDefaults and broadcast so every open
/// document window updates together.
enum AppSettings {
static let themeKey = "editorThemeID"
static let fontNameKey = "editorFontName"
static let fontSizeKey = "editorFontSize"
static let softWrapKey = "editorSoftWrap"
static let lineNumbersKey = "editorLineNumbers"
static let boardPageSizeKey = "boardPageSize"
static let boardOrientationKey = "boardOrientation"
static let boardSnapKey = "boardSnap"
static let didChange = Notification.Name("TextPlusAppearanceDidChange")
static var theme: EditorTheme {
EditorTheme.byID(UserDefaults.standard.string(forKey: themeKey)) }
static var fontSize: CGFloat {
let s = UserDefaults.standard.double(forKey: fontSizeKey)
return s >= 6 ? CGFloat(s) : 13
}
static var fontName: String {
UserDefaults.standard.string(forKey: fontNameKey) ?? "SF Mono"
}
static var softWrap: Bool {
UserDefaults.standard.object(forKey: softWrapKey) as? Bool ?? true
}
static var lineNumbers: Bool {
UserDefaults.standard.object(forKey: lineNumbersKey) as? Bool ?? true
}
static var boardPageSize: String {
UserDefaults.standard.string(forKey: boardPageSizeKey) ?? "letter"
}
static var boardOrientation: PageOrientation {
PageOrientation(rawValue: UserDefaults.standard.string(forKey: boardOrientationKey) ?? "portrait") ?? .portrait
}
static var boardSnap: Bool {
UserDefaults.standard.object(forKey: boardSnapKey) as? Bool ?? true
}
static var editorFont: NSFont {
font(named: fontName, size: fontSize)
}
static func font(named name: String, size: CGFloat) -> NSFont {
if name == "SF Mono" {
return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
if name == "System" {
return NSFont.systemFont(ofSize: size)
}
return NSFont(name: name, size: size)
?? NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
/// Curated font menu monospaced first (best for code/scraping), then a
/// couple of proportional faces for prose.
static let fontChoices: [String] = [
"SF Mono", "Menlo", "Monaco", "Courier New",
"System", "Helvetica Neue", "Georgia",
]
static func set(_ key: String, _ value: Any) {
UserDefaults.standard.set(value, forKey: key)
NotificationCenter.default.post(name: didChange, object: nil)
}
}
/// A complete editor color scheme. Markdown preview and the line-number gutter
/// derive their colors from the same palette so everything stays consistent.
struct EditorTheme: Equatable {
let id: String
let name: String
let isDark: Bool
let background: NSColor
let foreground: NSColor
let caret: NSColor
let selection: NSColor
let gutterBackground: NSColor
let gutterText: NSColor
let currentLine: NSColor
let accent: NSColor
let secondaryText: NSColor
static func rgb(_ r: Int, _ g: Int, _ b: Int, _ a: CGFloat = 1) -> NSColor {
NSColor(srgbRed: CGFloat(r) / 255, green: CGFloat(g) / 255, blue: CGFloat(b) / 255, alpha: a)
}
static let system = EditorTheme(
id: "system", name: "System", isDark: false,
background: .textBackgroundColor, foreground: .textColor,
caret: .textColor, selection: .selectedTextBackgroundColor,
gutterBackground: rgb(245, 245, 247), gutterText: rgb(160, 160, 168),
currentLine: rgb(0, 0, 0, 0.04), accent: .controlAccentColor,
secondaryText: .secondaryLabelColor)
static let dark = EditorTheme(
id: "dark", name: "Midnight", isDark: true,
background: rgb(24, 26, 31), foreground: rgb(222, 226, 233),
caret: rgb(120, 200, 255), selection: rgb(56, 84, 130),
gutterBackground: rgb(19, 21, 25), gutterText: rgb(95, 102, 115),
currentLine: rgb(255, 255, 255, 0.05), accent: rgb(120, 200, 255),
secondaryText: rgb(150, 158, 172))
static let sepia = EditorTheme(
id: "sepia", name: "Sepia", isDark: false,
background: rgb(247, 240, 224), foreground: rgb(60, 50, 38),
caret: rgb(150, 90, 40), selection: rgb(220, 200, 160),
gutterBackground: rgb(240, 231, 211), gutterText: rgb(170, 150, 120),
currentLine: rgb(120, 90, 40, 0.07), accent: rgb(165, 100, 40),
secondaryText: rgb(130, 112, 86))
static let solarized = EditorTheme(
id: "solarized", name: "Solarized", isDark: true,
background: rgb(0, 43, 54), foreground: rgb(147, 161, 161),
caret: rgb(38, 139, 210), selection: rgb(7, 54, 66),
gutterBackground: rgb(0, 37, 46), gutterText: rgb(88, 110, 117),
currentLine: rgb(255, 255, 255, 0.04), accent: rgb(42, 161, 152),
secondaryText: rgb(101, 123, 131))
static let nord = EditorTheme(
id: "nord", name: "Nord", isDark: true,
background: rgb(46, 52, 64), foreground: rgb(216, 222, 233),
caret: rgb(136, 192, 208), selection: rgb(67, 76, 94),
gutterBackground: rgb(40, 45, 56), gutterText: rgb(110, 120, 138),
currentLine: rgb(255, 255, 255, 0.05), accent: rgb(143, 188, 187),
secondaryText: rgb(150, 160, 178))
static let all: [EditorTheme] = [system, dark, sepia, solarized, nord]
static func byID(_ id: String?) -> EditorTheme {
all.first { $0.id == id } ?? system
}
var appearance: NSAppearance? {
NSAppearance(named: isDark ? .darkAqua : .aqua)
}
}

184
Sources/Board.swift Normal file
View File

@ -0,0 +1,184 @@
import AppKit
/// What a card on the freeform board is. The single `.mainText` card hosts the
/// document's real text view (its text == the .txt bytes, never serialized here).
/// Other kinds are board-only objects stored in the sidecar.
enum BoardCardKind: String, Codable {
case mainText, text, image, box
}
enum PageOrientation: String, Codable {
case portrait, landscape
var toggled: PageOrientation { self == .portrait ? .landscape : .portrait }
}
/// A named page-size preset (point dimensions at 72pt/inch). `portrait` here is
/// always width height; orientation swaps for non-square sizes.
enum PageSize: String, Codable, CaseIterable {
case letter, a4, legal, square, wide, note
var label: String {
switch self {
case .letter: return "US Letter"
case .a4: return "A4"
case .legal: return "US Legal"
case .square: return "Square"
case .wide: return "Wide 16:9"
case .note: return "Note"
}
}
var shortLabel: String {
switch self {
case .letter: return "Letter"
case .a4: return "A4"
case .legal: return "Legal"
case .square: return "Square"
case .wide: return "Wide"
case .note: return "Note"
}
}
private var portraitBase: CGSize {
switch self {
case .letter: return CGSize(width: 612, height: 792) // 8.5 × 11"
case .a4: return CGSize(width: 595, height: 842)
case .legal: return CGSize(width: 612, height: 1008) // 8.5 × 14"
case .square: return CGSize(width: 700, height: 700)
case .wide: return CGSize(width: 540, height: 960) // 9:16 portrait of 16:9
case .note: return CGSize(width: 300, height: 220)
}
}
func size(_ orientation: PageOrientation) -> CGSize {
if self == .square { return portraitBase }
let b = portraitBase
return orientation == .landscape ? CGSize(width: b.height, height: b.width) : b
}
static func from(_ raw: String?) -> PageSize { raw.flatMap(PageSize.init(rawValue:)) ?? .letter }
}
/// One object on the board, in world (board) coordinates. Codable with a
/// lenient decoder so a partial or older sidecar never throws.
struct BoardCard: Codable {
var id: UUID
var kind: BoardCardKind
var frame: CGRect // world coords (flipped board, origin top-left)
var z: Int // explicit stacking order
var text: String? // .text annotations only; nil for mainText
var orientation: PageOrientation? // mainText/text page orientation
var pageSize: String? // PageSize raw value for mainText/text cards
var imageFile: String? // relative filename inside foo.txt.board/
var fillHex: String? // .box
var strokeHex: String? // .box
var cornerRadius: CGFloat? // .box
var title: String? // optional label on any card
init(id: UUID = UUID(), kind: BoardCardKind, frame: CGRect, z: Int,
text: String? = nil, orientation: PageOrientation? = nil, pageSize: String? = nil,
imageFile: String? = nil, fillHex: String? = nil, strokeHex: String? = nil,
cornerRadius: CGFloat? = nil, title: String? = nil) {
self.id = id; self.kind = kind; self.frame = frame; self.z = z
self.text = text; self.orientation = orientation; self.pageSize = pageSize
self.imageFile = imageFile; self.fillHex = fillHex; self.strokeHex = strokeHex
self.cornerRadius = cornerRadius; self.title = title
}
enum CodingKeys: String, CodingKey {
case id, kind, frame, z, text, orientation, pageSize, imageFile, fillHex, strokeHex, cornerRadius, title
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
id = (try? c.decode(UUID.self, forKey: .id)) ?? UUID()
kind = (try? c.decode(BoardCardKind.self, forKey: .kind)) ?? .text
frame = (try? c.decode(CGRect.self, forKey: .frame)) ?? CGRect(x: 80, y: 80, width: 240, height: 160)
z = (try? c.decode(Int.self, forKey: .z)) ?? 0
text = try? c.decodeIfPresent(String.self, forKey: .text)
orientation = try? c.decodeIfPresent(PageOrientation.self, forKey: .orientation)
pageSize = try? c.decodeIfPresent(String.self, forKey: .pageSize)
imageFile = try? c.decodeIfPresent(String.self, forKey: .imageFile)
fillHex = try? c.decodeIfPresent(String.self, forKey: .fillHex)
strokeHex = try? c.decodeIfPresent(String.self, forKey: .strokeHex)
cornerRadius = try? c.decodeIfPresent(CGFloat.self, forKey: .cornerRadius)
title = try? c.decodeIfPresent(String.self, forKey: .title)
}
}
/// The persisted board state for a document. Stored in foo.txt.board/board.json.
/// Reuses the paint canvas's existing Codable doc verbatim for the shapes layer.
struct BoardDoc: Codable {
var version: Int
var cards: [BoardCard]
var shapes: DrawCanvasView.CanvasDoc
var magnification: CGFloat
var scrollOrigin: CGPoint
var boardBackgroundHex: String?
init(version: Int = 1, cards: [BoardCard], shapes: DrawCanvasView.CanvasDoc,
magnification: CGFloat = 1, scrollOrigin: CGPoint = .zero, boardBackgroundHex: String? = nil) {
self.version = version; self.cards = cards; self.shapes = shapes
self.magnification = magnification; self.scrollOrigin = scrollOrigin
self.boardBackgroundHex = boardBackgroundHex
}
enum CodingKeys: String, CodingKey {
case version, cards, shapes, magnification, scrollOrigin, boardBackgroundHex
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
version = (try? c.decode(Int.self, forKey: .version)) ?? 1
cards = (try? c.decode([BoardCard].self, forKey: .cards)) ?? []
shapes = (try? c.decode(DrawCanvasView.CanvasDoc.self, forKey: .shapes))
?? DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: 0, height: 0)
magnification = (try? c.decode(CGFloat.self, forKey: .magnification)) ?? 1
scrollOrigin = (try? c.decode(CGPoint.self, forKey: .scrollOrigin)) ?? .zero
boardBackgroundHex = try? c.decodeIfPresent(String.self, forKey: .boardBackgroundHex)
}
/// A fresh board with a single empty main page card, centered in the world.
static func makeDefault(worldSize: CGSize,
pageSize: PageSize = .from(AppSettings.boardPageSize),
orientation: PageOrientation = AppSettings.boardOrientation) -> BoardDoc {
let page = pageSize.size(orientation)
let origin = CGPoint(x: (worldSize.width - page.width) / 2,
y: max(120, (worldSize.height - page.height) / 2 - 200))
let main = BoardCard(kind: .mainText,
frame: CGRect(origin: origin, size: page),
z: 0, orientation: orientation, pageSize: pageSize.rawValue)
let empty = DrawCanvasView.CanvasDoc(backgroundHex: "#00000000", elements: [], width: worldSize.width, height: worldSize.height)
return BoardDoc(cards: [main], shapes: empty)
}
}
extension JSONEncoder {
/// Hand-inspectable sidecar JSON, matching the existing canvas sidecar style.
static let boardEncoder: JSONEncoder = {
let e = JSONEncoder()
e.outputFormatting = [.prettyPrinted, .sortedKeys]
return e
}()
}
/// Shared plaintext-safety configuration for any NSTextView used as an editor
/// surface (the document editor and board text cards), so they behave identically.
enum TextViewConfig {
static func harden(_ tv: NSTextView) {
tv.isRichText = false
tv.importsGraphics = false
tv.usesFontPanel = false
tv.allowsUndo = true
tv.isAutomaticQuoteSubstitutionEnabled = false
tv.isAutomaticDashSubstitutionEnabled = false
tv.isAutomaticTextReplacementEnabled = false
tv.isAutomaticSpellingCorrectionEnabled = false
tv.isAutomaticLinkDetectionEnabled = false
tv.isAutomaticDataDetectionEnabled = false
tv.smartInsertDeleteEnabled = false
tv.isContinuousSpellCheckingEnabled = false
tv.enabledTextCheckingTypes = 0
}
}

1348
Sources/BoardContainer.swift Normal file

File diff suppressed because it is too large Load Diff

556
Sources/Document.swift Normal file
View File

@ -0,0 +1,556 @@
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)
}
}
}

395
Sources/DrawingCanvas.swift Normal file
View File

@ -0,0 +1,395 @@
import AppKit
enum DrawTool: String, CaseIterable {
case pen, marker, line, arrow, rect, oval, text, eraser
var symbol: String {
switch self {
case .pen: return "pencil.tip"
case .marker: return "highlighter"
case .line: return "line.diagonal"
case .arrow: return "arrow.up.right"
case .rect: return "rectangle"
case .oval: return "oval"
case .text: return "textformat"
case .eraser: return "eraser"
}
}
var label: String { rawValue.capitalized }
}
/// One drawn element. Freehand tools keep a point list; shapes keep two corner
/// points; text keeps an origin + string. Stored as plain values so the whole
/// canvas serializes to JSON for an editable sidecar.
struct DrawElement: Codable {
var tool: String
var colorHex: String
var alpha: CGFloat
var lineWidth: CGFloat
var filled: Bool
var points: [CGPoint]
var text: String
var drawTool: DrawTool { DrawTool(rawValue: tool) ?? .pen }
var color: NSColor { NSColor(hex: colorHex)?.withAlphaComponent(alpha) ?? .black }
}
/// The drawing surface: an unflattened vector model rendered each frame, with
/// per-element undo/redo and PNG/JSON export. Tool/color/width are driven by
/// the palette to its left.
final class DrawCanvasView: NSView {
var tool: DrawTool = .pen
var strokeColor: NSColor = .systemRed
var fillEnabled = false
var lineWidth: CGFloat = 3
var canvasBackground: NSColor = .white { didSet { needsDisplay = true } }
/// When false, the view is transparent to mouse events (used as the board's
/// bottom shapes layer so card drags win unless a draw tool is active).
var hitTestEnabled = true
override func hitTest(_ point: NSPoint) -> NSView? {
hitTestEnabled ? super.hitTest(point) : nil
}
private(set) var elements: [DrawElement] = []
private var current: DrawElement?
private let undoMgr = UndoManager()
/// Called whenever the model changes, so the host can mark the sidecar dirty.
var onChange: (() -> Void)?
override var isFlipped: Bool { true }
override var acceptsFirstResponder: Bool { true }
override var undoManager: UndoManager? { undoMgr }
// MARK: - Rendering
override func draw(_ dirtyRect: NSRect) {
canvasBackground.setFill()
bounds.fill()
for element in elements { render(element) }
if let current { render(current) }
}
private func render(_ e: DrawElement) {
let color = e.color
color.setStroke()
color.setFill()
let path = NSBezierPath()
path.lineWidth = e.lineWidth
path.lineCapStyle = .round
path.lineJoinStyle = .round
switch e.drawTool {
case .pen, .marker, .eraser:
guard let first = e.points.first else { return }
path.move(to: first)
for p in e.points.dropFirst() { path.line(to: p) }
if e.drawTool == .marker { color.withAlphaComponent(min(e.alpha, 0.4)).setStroke() }
path.stroke()
case .line:
guard e.points.count >= 2 else { return }
path.move(to: e.points[0]); path.line(to: e.points[1]); path.stroke()
case .arrow:
guard e.points.count >= 2 else { return }
drawArrow(from: e.points[0], to: e.points[1], width: e.lineWidth, color: color)
case .rect:
guard e.points.count >= 2 else { return }
let r = NSRect(corner: e.points[0], to: e.points[1])
let rp = NSBezierPath(rect: r); rp.lineWidth = e.lineWidth
if e.filled { color.withAlphaComponent(0.25 * e.alpha).setFill(); rp.fill() }
color.setStroke(); rp.stroke()
case .oval:
guard e.points.count >= 2 else { return }
let r = NSRect(corner: e.points[0], to: e.points[1])
let op = NSBezierPath(ovalIn: r); op.lineWidth = e.lineWidth
if e.filled { color.withAlphaComponent(0.25 * e.alpha).setFill(); op.fill() }
color.setStroke(); op.stroke()
case .text:
guard let origin = e.points.first else { return }
let font = NSFont.systemFont(ofSize: max(11, e.lineWidth * 5))
(e.text as NSString).draw(at: origin, withAttributes: [.font: font, .foregroundColor: color])
}
}
private func drawArrow(from: NSPoint, to: NSPoint, width: CGFloat, color: NSColor) {
let shaft = NSBezierPath()
shaft.lineWidth = width
shaft.lineCapStyle = .round
shaft.move(to: from); shaft.line(to: to)
color.setStroke(); shaft.stroke()
let angle = atan2(to.y - from.y, to.x - from.x)
let headLen = max(10, width * 4)
let spread = CGFloat.pi / 7
let head = NSBezierPath()
head.move(to: to)
head.line(to: NSPoint(x: to.x - headLen * cos(angle - spread), y: to.y - headLen * sin(angle - spread)))
head.move(to: to)
head.line(to: NSPoint(x: to.x - headLen * cos(angle + spread), y: to.y - headLen * sin(angle + spread)))
head.lineWidth = width
head.lineCapStyle = .round
head.stroke()
}
// MARK: - Mouse drawing
override func mouseDown(with event: NSEvent) {
let p = convert(event.locationInWindow, from: nil)
if tool == .text {
beginTextEntry(at: p)
return
}
if tool == .eraser {
eraseHits(at: p)
return
}
current = DrawElement(
tool: tool.rawValue,
colorHex: strokeColor.hexString,
alpha: strokeColor.alphaComponent,
lineWidth: lineWidth,
filled: fillEnabled,
points: [p],
text: ""
)
needsDisplay = true
}
override func mouseDragged(with event: NSEvent) {
guard current != nil else { return }
let p = convert(event.locationInWindow, from: nil)
switch tool {
case .pen, .marker:
current?.points.append(p)
case .eraser:
eraseHits(at: p)
return
default:
if current!.points.count < 2 { current?.points.append(p) } else { current?.points[1] = p }
}
needsDisplay = true
}
override func mouseUp(with event: NSEvent) {
guard let element = current else { return }
current = nil
// Ignore a stray click that produced no real shape.
if element.drawTool != .text, element.points.count < 2,
element.drawTool != .pen, element.drawTool != .marker {
needsDisplay = true
return
}
commit(element)
}
// MARK: - Model mutation (undoable)
private func commit(_ element: DrawElement) {
elements.append(element)
undoMgr.registerUndo(withTarget: self) { canvas in
canvas.removeLast(restoring: element)
}
undoMgr.setActionName("Draw \(element.drawTool.label)")
needsDisplay = true
onChange?()
}
private func removeLast(restoring element: DrawElement) {
guard !elements.isEmpty else { return }
let removed = elements.removeLast()
undoMgr.registerUndo(withTarget: self) { canvas in
canvas.commit(removed)
}
needsDisplay = true
onChange?()
}
private func eraseHits(at p: NSPoint) {
let hitRadius = max(8, lineWidth * 3)
let survivors = elements.filter { !$0.hits(point: p, within: hitRadius) }
guard survivors.count != elements.count else { return }
let before = elements
elements = survivors
undoMgr.registerUndo(withTarget: self) { canvas in
canvas.restore(before)
}
undoMgr.setActionName("Erase")
needsDisplay = true
onChange?()
}
private func restore(_ snapshot: [DrawElement]) {
let before = elements
elements = snapshot
undoMgr.registerUndo(withTarget: self) { canvas in canvas.restore(before) }
needsDisplay = true
onChange?()
}
private func beginTextEntry(at p: NSPoint) {
let field = NSTextField(frame: NSRect(x: p.x, y: p.y, width: 200, height: 24))
field.font = NSFont.systemFont(ofSize: max(11, lineWidth * 5))
field.textColor = strokeColor
field.backgroundColor = .clear
field.isBordered = false
field.focusRingType = .none
field.placeholderString = "type, ⏎"
field.target = self
field.action = #selector(commitTextField(_:))
field.tag = 0
addSubview(field)
window?.makeFirstResponder(field)
textEntryOrigin = p
activeTextField = field
}
private var activeTextField: NSTextField?
private var textEntryOrigin: NSPoint = .zero
@objc private func commitTextField(_ sender: NSTextField) {
let value = sender.stringValue
sender.removeFromSuperview()
activeTextField = nil
guard !value.isEmpty else { return }
var element = DrawElement(tool: DrawTool.text.rawValue,
colorHex: strokeColor.hexString, alpha: strokeColor.alphaComponent,
lineWidth: lineWidth, filled: false,
points: [textEntryOrigin], text: value)
// Nudge baseline so text sits where the click landed.
element.points = [NSPoint(x: textEntryOrigin.x, y: textEntryOrigin.y)]
commit(element)
}
// MARK: - Commands
func clearAll() {
guard !elements.isEmpty else { return }
let before = elements
elements = []
undoMgr.registerUndo(withTarget: self) { canvas in canvas.restore(before) }
undoMgr.setActionName("Clear Canvas")
needsDisplay = true
onChange?()
}
func undo() { if undoMgr.canUndo { undoMgr.undo() } }
func redo() { if undoMgr.canRedo { undoMgr.redo() } }
// MARK: - Export / persistence
func pngData() -> Data? {
guard let rep = bitmapImageRepForCachingDisplay(in: bounds) else { return nil }
cacheDisplay(in: bounds, to: rep)
return rep.representation(using: .png, properties: [:])
}
struct CanvasDoc: Codable {
var backgroundHex: String
var elements: [DrawElement]
var width: CGFloat
var height: CGFloat
}
func serialized() -> Data? {
let doc = CanvasDoc(backgroundHex: canvasBackground.hexString, elements: elements,
width: bounds.width, height: bounds.height)
return try? JSONEncoder().encode(doc)
}
func load(from data: Data) {
guard let doc = try? JSONDecoder().decode(CanvasDoc.self, from: data) else { return }
if let bg = NSColor(hex: doc.backgroundHex) { canvasBackground = bg }
elements = doc.elements
undoMgr.removeAllActions()
needsDisplay = true
}
var isEmpty: Bool { elements.isEmpty }
/// Bounding rect of all drawn elements (nil if empty), for fit/export.
func contentBounds() -> NSRect? {
var rect: NSRect?
for e in elements {
for p in e.points {
let r = NSRect(x: p.x, y: p.y, width: 0, height: 0)
rect = rect?.union(r) ?? r
}
}
return rect?.insetBy(dx: -12, dy: -12)
}
}
// MARK: - Geometry helpers
private extension DrawElement {
func hits(point p: NSPoint, within r: CGFloat) -> Bool {
switch drawTool {
case .pen, .marker, .eraser, .line, .arrow:
for i in 0..<max(0, points.count - 1) {
if distance(p, toSegment: points[i], points[i + 1]) <= r + lineWidth { return true }
}
if let only = points.first, points.count == 1 { return only.distance(to: p) <= r }
return false
case .rect, .oval:
guard points.count >= 2 else { return false }
return NSRect(corner: points[0], to: points[1]).insetBy(dx: -r, dy: -r).contains(p)
case .text:
guard let o = points.first else { return false }
return NSRect(x: o.x - r, y: o.y - r, width: 200, height: 28).contains(p)
}
}
func distance(_ p: NSPoint, toSegment a: NSPoint, _ b: NSPoint) -> CGFloat {
let dx = b.x - a.x, dy = b.y - a.y
let lenSq = dx * dx + dy * dy
if lenSq == 0 { return p.distance(to: a) }
var t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq
t = max(0, min(1, t))
return p.distance(to: NSPoint(x: a.x + t * dx, y: a.y + t * dy))
}
}
extension NSPoint {
func distance(to o: NSPoint) -> CGFloat { hypot(x - o.x, y - o.y) }
}
extension NSRect {
init(corner a: NSPoint, to b: NSPoint) {
self.init(x: min(a.x, b.x), y: min(a.y, b.y), width: abs(a.x - b.x), height: abs(a.y - b.y))
}
}
// MARK: - Color hex
extension NSColor {
/// "#RRGGBB" when opaque, "#RRGGBBAA" when translucent (so box/shape alpha
/// survives a save).
var hexString: String {
guard let c = usingColorSpace(.sRGB) else { return "#000000" }
let r = Int(round(c.redComponent * 255))
let g = Int(round(c.greenComponent * 255))
let b = Int(round(c.blueComponent * 255))
let a = Int(round(c.alphaComponent * 255))
return a >= 255 ? String(format: "#%02X%02X%02X", r, g, b)
: String(format: "#%02X%02X%02X%02X", r, g, b, a)
}
convenience init?(hex: String) {
var s = hex.trimmingCharacters(in: .whitespaces)
if s.hasPrefix("#") { s.removeFirst() }
if s.count == 6, let v = UInt32(s, radix: 16) {
self.init(srgbRed: CGFloat((v >> 16) & 0xFF) / 255,
green: CGFloat((v >> 8) & 0xFF) / 255,
blue: CGFloat(v & 0xFF) / 255, alpha: 1)
} else if s.count == 8, let v = UInt32(s, radix: 16) {
self.init(srgbRed: CGFloat((v >> 24) & 0xFF) / 255,
green: CGFloat((v >> 16) & 0xFF) / 255,
blue: CGFloat((v >> 8) & 0xFF) / 255,
alpha: CGFloat(v & 0xFF) / 255)
} else {
return nil
}
}
}

739
Sources/HTMLScanner.swift Normal file
View File

@ -0,0 +1,739 @@
import Foundation
/// Lightweight streaming HTML tokenizer + selector tools for inspector-copied
/// markup. Not a spec HTML parser built for "paste <body> from devtools,
/// find the repeating rows, pull their data" scraping workflows.
///
/// Selector syntax: `tag`, `#id`, `.class`, compounds (`tr.shortcut_navigable`),
/// attribute filters (`[data-id]`, `[!class]`, `[rel=tag]`, `[href^=/seller/]`),
/// space-separated descendant chains, and a trailing `@attr` to extract an
/// attribute value from the matched element.
enum HTMLScanner {
// MARK: - Public: selector summary
struct SummaryData {
let entries: [(selector: String, count: Int, sample: String)]
let elementCount: Int
}
static func summarize(html: String) -> SummaryData {
var counts: [String: Int] = [:]
var samples: [String: String] = [:]
struct Entry {
let tag: String
let key: String?
var sampling: Bool
var buf: String
}
var stack: [Entry] = []
var totalElements = 0
func finalize(_ entry: Entry) {
guard let key = entry.key, entry.sampling else { return }
if samples[key, default: ""].isEmpty {
samples[key] = String(entry.buf.prefix(90))
}
}
tokenize(html, openTag: { tag, attrs, closed in
let id = normalizedID(attrs)
let classes = splitClasses(attrs["class"])
var key: String?
if id != nil || !classes.isEmpty {
key = selectorString(tag: tag, id: id, classes: classes)
counts[key!, default: 0] += 1
totalElements += 1
}
if closed { return }
// Keep sampling until a NON-empty sample lands, so a first
// occurrence that wraps only an <img> doesn't lose the sample.
let sampling = key.map { samples[$0, default: ""].isEmpty } ?? false
stack.append(Entry(tag: tag, key: key, sampling: sampling, buf: ""))
}, closeTag: { tag in
guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return }
for entry in stack[matchIdx...].reversed() { finalize(entry) }
stack.removeSubrange(matchIdx...)
}, text: { raw in
let collapsed = collapseWhitespace(decodeEntities(raw))
guard !collapsed.isEmpty else { return }
for i in stack.indices where stack[i].sampling && stack[i].buf.count < 90 {
stack[i].buf += (stack[i].buf.isEmpty ? "" : " ") + collapsed
}
})
for entry in stack.reversed() { finalize(entry) }
let sorted = counts.sorted { $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key }
return SummaryData(
entries: sorted.map { (selector: $0.key, count: $0.value, sample: samples[$0.key] ?? "") },
elementCount: totalElements
)
}
static func formatSummary(_ data: SummaryData, sourceName: String) -> String {
var out = "Selector summary of \(sourceName)\n"
out += "\(data.elementCount) elements with an id or class, \(data.entries.count) unique selectors.\n"
out += "Repeating rows (your data) sort to the top. Next: Tools → Extract Text for Selector…\n\n"
for entry in data.entries {
out += String(format: "%5d× %@\n", entry.count, entry.selector)
if !entry.sample.isEmpty {
out += "\(entry.sample)\n"
}
}
return out
}
// MARK: - Public: extract text for a selector
struct ExtractResult {
let matchCount: Int
let body: String
}
/// Returns nil if the selector string can't be parsed. A trailing `@attr`
/// prepends that attribute's value as a tab-separated first column.
static func extractText(html: String, selector: String, sourceName: String) -> ExtractResult? {
guard let compounds = parseSelector(selector) else { return nil }
let extractAttr = compounds.last?.extractAttr
struct Entry {
let tag: String
let id: String?
let classes: Set<String>
let attrs: [String: String]
var isCaptureRoot: Bool
}
var stack: [Entry] = []
var capturing = false
var buf = ""
var rootAttrValue = ""
var records: [String] = []
func selectorMatches() -> Bool {
matchChain(compounds, stack: stack.map { ($0.tag, $0.id, $0.classes, $0.attrs) }, scopeStart: 0)
}
func attrValue(_ attrs: [String: String]) -> String {
extractAttr.flatMap { attrs[$0] } ?? ""
}
func finishRecord() {
var record = buf.trimmingCharacters(in: CharacterSet(charactersIn: " \t"))
if extractAttr != nil {
record = rootAttrValue + "\t" + record
}
records.append(record)
buf = ""
rootAttrValue = ""
capturing = false
}
tokenize(html, openTag: { tag, attrs, closed in
let entry = Entry(tag: tag, id: normalizedID(attrs),
classes: Set(splitClasses(attrs["class"])),
attrs: attrs, isCaptureRoot: false)
if closed {
if !capturing {
stack.append(entry)
if selectorMatches() {
records.append(extractAttr != nil ? attrValue(attrs) + "\t" : "")
}
stack.removeLast()
}
return
}
stack.append(entry)
if selectorMatches() {
if capturing {
// A new match opening mid-capture means the previous row's
// close tag was missing (raw source) or rows nest either
// way, finish the old record so rows don't silently merge.
finishRecord()
if let oldRoot = stack.firstIndex(where: { $0.isCaptureRoot }) {
stack[oldRoot].isCaptureRoot = false
}
}
stack[stack.count - 1].isCaptureRoot = true
capturing = true
buf = ""
rootAttrValue = attrValue(attrs)
}
}, closeTag: { tag in
guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return }
let popped = stack[matchIdx...]
stack.removeSubrange(matchIdx...)
if popped.contains(where: { $0.isCaptureRoot }) {
finishRecord()
} else if capturing, tag == "td" || tag == "th",
!buf.isEmpty, !buf.hasSuffix("\t") {
buf += "\t" // cell boundary tab, so table rows come out TSV-ish
}
}, text: { raw in
guard capturing else { return }
let collapsed = collapseWhitespace(decodeEntities(raw))
guard !collapsed.isEmpty else { return }
if !buf.isEmpty && !buf.hasSuffix("\t") { buf += " " }
buf += collapsed
})
if capturing { finishRecord() } // unclosed capture at EOF
var out = "# \(records.count) match\(records.count == 1 ? "" : "es") for `\(selector)` in \(sourceName)\n"
out += records.joined(separator: "\n")
if !records.isEmpty { out += "\n" }
return ExtractResult(matchCount: records.count, body: out)
}
// MARK: - Public: structured row extraction (scrape profiles)
struct RowExtraction {
let header: [String]
let rows: [[String]]
}
/// For each element matching rowSelector, capture named fields by selector
/// scoped to that row's subtree (the row element itself included, so
/// "tr@data-release-id" reads an attribute off the row). First match per
/// field wins. Returns nil if any selector fails to parse.
static func extractRows(html: String, rowSelector: String,
fields: [(name: String, selector: String)]) -> RowExtraction? {
guard let rowCompounds = parseSelector(rowSelector) else { return nil }
struct FieldSpec {
let name: String
let compounds: [Compound]
let attr: String?
}
var specs: [FieldSpec] = []
for field in fields {
guard let comps = parseSelector(field.selector) else { return nil }
specs.append(FieldSpec(name: field.name, compounds: comps, attr: comps.last?.extractAttr))
}
typealias StackEl = (tag: String, id: String?, classes: Set<String>, attrs: [String: String])
enum FieldState {
case pending
case capturing(rootIndex: Int, buf: String)
case done(String)
}
var stack: [StackEl] = []
var inRow = false
var rowRootIndex = -1
var fieldStates: [FieldState] = []
var rows: [[String]] = []
func startRow() {
inRow = true
rowRootIndex = stack.count - 1
fieldStates = Array(repeating: .pending, count: specs.count)
tryMatchFields(closedAttrs: nil)
}
func finishRow() {
let columns = fieldStates.map { state -> String in
switch state {
case .pending: return ""
case .capturing(_, let buf): return buf.trimmingCharacters(in: .whitespaces)
case .done(let value): return value.trimmingCharacters(in: .whitespaces)
}
}
rows.append(columns)
inRow = false
rowRootIndex = -1
fieldStates = []
}
// closedAttrs non-nil = the just-seen element is void/self-closed and
// sits on top of the stack only transiently.
func tryMatchFields(closedAttrs: [String: String]?) {
guard inRow else { return }
for i in specs.indices {
guard case .pending = fieldStates[i] else { continue }
guard matchChain(specs[i].compounds, stack: stack, scopeStart: rowRootIndex) else { continue }
if let attr = specs[i].attr {
fieldStates[i] = .done(stack[stack.count - 1].attrs[attr] ?? "")
} else if closedAttrs != nil {
fieldStates[i] = .done("") // void element has no text
} else {
fieldStates[i] = .capturing(rootIndex: stack.count - 1, buf: "")
}
}
}
tokenize(html, openTag: { tag, attrs, closed in
let el: StackEl = (tag, normalizedID(attrs), Set(splitClasses(attrs["class"])), attrs)
if closed {
stack.append(el)
if inRow { tryMatchFields(closedAttrs: attrs) }
stack.removeLast()
return
}
stack.append(el)
if matchChain(rowCompounds, stack: stack, scopeStart: 0) {
if inRow { finishRow() } // unclosed previous row
startRow()
} else if inRow {
tryMatchFields(closedAttrs: nil)
}
}, closeTag: { tag in
guard let matchIdx = stack.lastIndex(where: { $0.tag == tag }) else { return }
for i in fieldStates.indices {
if case .capturing(let root, let buf) = fieldStates[i], root >= matchIdx {
fieldStates[i] = .done(buf)
}
}
if inRow && rowRootIndex >= matchIdx { finishRow() }
stack.removeSubrange(matchIdx...)
}, text: { raw in
guard inRow else { return }
let collapsed = collapseWhitespace(decodeEntities(raw))
guard !collapsed.isEmpty else { return }
for i in fieldStates.indices {
if case .capturing(let root, var buf) = fieldStates[i] {
if !buf.isEmpty { buf += " " }
buf += collapsed
fieldStates[i] = .capturing(rootIndex: root, buf: buf)
}
}
})
if inRow { finishRow() }
return RowExtraction(header: specs.map { $0.name }, rows: rows)
}
/// True if the selector parses (for validating user/profile input).
static func isValidSelector(_ selector: String) -> Bool {
parseSelector(selector) != nil
}
// MARK: - Selector parsing & matching
private static func parseSelector(_ selector: String) -> [Compound]? {
let parts = selector.split(separator: " ").map(String.init)
guard !parts.isEmpty else { return nil }
var compounds: [Compound] = []
for part in parts {
guard let c = Compound(token: part) else { return nil }
compounds.append(c)
}
// @attr extraction is only meaningful on the final compound
for c in compounds.dropLast() where c.extractAttr != nil { return nil }
return compounds
}
/// Match the last compound against the top of the stack and earlier
/// compounds against ancestors in order, never looking below scopeStart.
private static func matchChain(_ compounds: [Compound],
stack: [(tag: String, id: String?, classes: Set<String>, attrs: [String: String])],
scopeStart: Int) -> Bool {
guard let last = stack.last,
compounds[compounds.count - 1].matches(tag: last.tag, id: last.id, classes: last.classes, attrs: last.attrs)
else { return false }
var ci = compounds.count - 2
var si = stack.count - 2
while ci >= 0 {
while si >= scopeStart,
!compounds[ci].matches(tag: stack[si].tag, id: stack[si].id, classes: stack[si].classes, attrs: stack[si].attrs) {
si -= 1
}
if si < scopeStart { return false }
ci -= 1
si -= 1
}
return true
}
private struct AttrFilter {
enum Op { case present, absent, equals, prefix }
let name: String
let op: Op
let value: String
init?(_ content: String) {
let s = content.trimmingCharacters(in: .whitespaces)
guard !s.isEmpty else { return nil }
if s.hasPrefix("!") {
let n = String(s.dropFirst()).trimmingCharacters(in: .whitespaces).lowercased()
guard !n.isEmpty else { return nil }
name = n; op = .absent; value = ""
} else if let r = s.range(of: "^=") {
let n = String(s[..<r.lowerBound]).trimmingCharacters(in: .whitespaces).lowercased()
guard !n.isEmpty else { return nil }
name = n; op = .prefix; value = AttrFilter.stripQuotes(String(s[r.upperBound...]))
} else if let eq = s.firstIndex(of: "=") {
let n = String(s[..<eq]).trimmingCharacters(in: .whitespaces).lowercased()
guard !n.isEmpty else { return nil }
name = n; op = .equals; value = AttrFilter.stripQuotes(String(s[s.index(after: eq)...]))
} else {
name = s.lowercased(); op = .present; value = ""
}
}
static func stripQuotes(_ v: String) -> String {
let t = v.trimmingCharacters(in: .whitespaces)
if t.count >= 2,
(t.hasPrefix("\"") && t.hasSuffix("\"")) || (t.hasPrefix("'") && t.hasSuffix("'")) {
return String(t.dropFirst().dropLast())
}
return t
}
func matches(_ attrs: [String: String]) -> Bool {
switch op {
case .present: return attrs[name] != nil
case .absent: return attrs[name] == nil
case .equals: return attrs[name] == value
case .prefix: return attrs[name]?.hasPrefix(value) ?? false
}
}
}
private struct Compound {
var tag: String?
var id: String?
var classes: [String] = []
var filters: [AttrFilter] = []
var extractAttr: String?
init?(token: String) {
var mode: Character = "t"
var current = ""
func commit() -> Bool {
guard !current.isEmpty else { return false }
switch mode {
case "t":
guard tag == nil else { return false } // "div[x]y" two tags
let t = current.lowercased()
// Reject combinators/pseudo junk (">", "+", "td:nth-child(2)")
// so the "Bad Selector" path fires instead of 0 matches.
// ':' stays legal (namespaced tags); ids/classes aren't
// validated real class values contain odd characters.
guard t == "*" || t.allSatisfy({
$0.isLetter || $0.isNumber || $0 == "-" || $0 == "_" || $0 == ":"
}) else { return false }
tag = t
case "#": id = current
default: classes.append(current)
}
current = ""
return true
}
let chars = Array(token)
var i = 0
while i < chars.count {
let ch = chars[i]
if ch == "[" {
if !current.isEmpty {
guard commit() else { return nil }
} else if mode != "t" {
return nil // ".[x]"
}
mode = "t"
guard let close = chars[i...].firstIndex(of: "]") else { return nil }
guard let filter = AttrFilter(String(chars[(i + 1)..<close])) else { return nil }
filters.append(filter)
i = close + 1
continue
}
if ch == "@" {
if !current.isEmpty {
guard commit() else { return nil }
} else if mode != "t" {
return nil // "div.@href"
}
mode = "t"
let rest = String(chars[(i + 1)...]).lowercased()
guard !rest.isEmpty, !rest.contains("["), !rest.contains("]"), !rest.contains("@") else { return nil }
extractAttr = rest
i = chars.count
continue
}
if ch == "." || ch == "#" {
// A '.' glued to a digit is part of the name (Tailwind
// decimal classes like p-2.5), not a class separator.
if ch == ".", !current.isEmpty, i + 1 < chars.count, chars[i + 1].isNumber {
current.append(ch)
i += 1
continue
}
if !current.isEmpty {
guard commit() else { return nil }
} else if mode != "t" {
return nil // "..", ".#"
}
mode = ch
i += 1
continue
}
current.append(ch)
i += 1
}
if !current.isEmpty {
guard commit() else { return nil }
} else if mode != "t" {
return nil // trailing "." or "#"
}
if tag == "*" { tag = nil }
if tag == nil && id == nil && classes.isEmpty && filters.isEmpty && extractAttr == nil { return nil }
}
func matches(tag t: String, id i: String?, classes cs: Set<String>, attrs: [String: String]) -> Bool {
if let tag, tag != t { return false }
if let id, id != i { return false }
for c in classes where !cs.contains(c) { return false }
for f in filters where !f.matches(attrs) { return false }
return true
}
}
// MARK: - Tokenizer
private static let voidTags: Set<String> = [
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr",
]
private static let rawTextTags: Set<String> = ["script", "style", "textarea", "title"]
// Tags that implicitly close a same-tag sibling (covers unclosed
// <li>/<td>/<p> runs in raw page source; inspector copies are balanced).
private static let siblingClosingTags: Set<String> = ["li", "tr", "td", "th", "p", "dt", "dd", "option"]
// Inline tags the sibling-close rule may look "through" an unclosed
// <b> between two <li>s must not defeat the implicit close. Bounded so
// legitimately nested lists (li inside ul inside li) stay nested.
private static let inlineTags: Set<String> = [
"a", "abbr", "b", "code", "em", "font", "i", "label", "mark",
"s", "small", "span", "strong", "sub", "sup", "time", "u",
]
/// Walks the markup once. openTag receives the tag name, all attributes
/// (lowercased names, entity-decoded values, first occurrence wins), and
/// whether the element can't have children (self-closed or void).
private static func tokenize(_ html: String,
openTag: (String, [String: String], Bool) -> Void,
closeTag: (String) -> Void,
text: (String) -> Void) {
let bytes = Array(html.utf8)
let n = bytes.count
let lt = UInt8(ascii: "<"), gt = UInt8(ascii: ">")
let slash = UInt8(ascii: "/"), bang = UInt8(ascii: "!")
let eq = UInt8(ascii: "="), dash = UInt8(ascii: "-")
let dq = UInt8(ascii: "\""), sq = UInt8(ascii: "'")
func isSpace(_ b: UInt8) -> Bool { b == 0x20 || b == 0x09 || b == 0x0A || b == 0x0D || b == 0x0C }
func isAlpha(_ b: UInt8) -> Bool { (b | 0x20) >= 0x61 && (b | 0x20) <= 0x7A }
func isNameByte(_ b: UInt8) -> Bool {
isAlpha(b) || (b >= 0x30 && b <= 0x39) || b == dash || b == UInt8(ascii: "_") || b == UInt8(ascii: ":")
}
var i = 0
var textStart = 0
var openStack: [String] = []
func flushText(upTo end: Int) {
if end > textStart {
text(String(decoding: bytes[textStart..<end], as: UTF8.self))
}
}
func emitClose(_ tag: String) {
if let idx = openStack.lastIndex(of: tag) {
openStack.removeSubrange(idx...)
}
closeTag(tag)
}
while i < n {
guard bytes[i] == lt, i + 1 < n else { i += 1; continue }
let next = bytes[i + 1]
if next == bang {
flushText(upTo: i)
if i + 3 < n, bytes[i + 2] == dash, bytes[i + 3] == dash {
// Scan from i+2 so the empty comments <!--> and <!---> close
// (their terminator overlaps the opening dashes).
var j = i + 2
while j + 2 < n && !(bytes[j] == dash && bytes[j + 1] == dash && bytes[j + 2] == gt) { j += 1 }
i = min(n, j + 3)
} else {
var j = i + 2
while j < n && bytes[j] != gt { j += 1 }
i = min(n, j + 1)
}
textStart = i
} else if next == slash {
flushText(upTo: i)
var j = i + 2
var name = ""
while j < n, isNameByte(bytes[j]) { name.append(Character(UnicodeScalar(bytes[j]))); j += 1 }
while j < n && bytes[j] != gt { j += 1 }
i = min(n, j + 1)
textStart = i
if !name.isEmpty { emitClose(name.lowercased()) }
} else if isAlpha(next) {
flushText(upTo: i)
var j = i + 1
var name = ""
while j < n, isNameByte(bytes[j]) { name.append(Character(UnicodeScalar(bytes[j]))); j += 1 }
let tag = name.lowercased()
var attrs: [String: String] = [:]
var selfClosed = false
attrs: while j < n {
while j < n, isSpace(bytes[j]) { j += 1 }
if j >= n { break }
if bytes[j] == gt { j += 1; break }
if bytes[j] == slash {
if j + 1 < n, bytes[j + 1] == gt { selfClosed = true; j += 2; break }
j += 1
continue
}
var attrName = ""
while j < n, !isSpace(bytes[j]), bytes[j] != eq, bytes[j] != gt, bytes[j] != slash {
attrName.append(Character(UnicodeScalar(bytes[j])))
j += 1
}
var value = ""
while j < n, isSpace(bytes[j]) { j += 1 }
if j < n, bytes[j] == eq {
j += 1
while j < n, isSpace(bytes[j]) { j += 1 }
if j < n, bytes[j] == dq || bytes[j] == sq {
let quote = bytes[j]
j += 1
let start = j
while j < n, bytes[j] != quote { j += 1 }
value = String(decoding: bytes[start..<j], as: UTF8.self)
if j < n { j += 1 }
} else {
let start = j
while j < n, !isSpace(bytes[j]), bytes[j] != gt { j += 1 }
value = String(decoding: bytes[start..<j], as: UTF8.self)
}
}
if !attrName.isEmpty {
let key = attrName.lowercased()
if attrs[key] == nil { attrs[key] = decodeEntities(value) }
}
if j >= n { break attrs }
}
i = j
textStart = i
if siblingClosingTags.contains(tag) {
var idx = openStack.count - 1
while idx >= 0, inlineTags.contains(openStack[idx]) { idx -= 1 }
if idx >= 0, openStack[idx] == tag { emitClose(tag) }
}
let childless = selfClosed || voidTags.contains(tag)
openTag(tag, attrs, childless)
if !childless && !rawTextTags.contains(tag) {
openStack.append(tag)
}
if !childless && rawTextTags.contains(tag) {
// Skip raw content (scripts, styles) up to the real closing tag.
let needle = Array("</\(tag)".utf8)
var k = i
search: while k + needle.count <= n {
var m = 0
while m < needle.count {
if (bytes[k + m] | 0x20) != needle[m] { k += 1; continue search }
m += 1
}
// "</scripty" must not end a <script>: the spec requires
// '>', '/', or whitespace after the tag name.
let after = k + needle.count
if after < n {
let b = bytes[after]
if !(b == gt || b == slash || isSpace(b)) { k += 1; continue search }
}
break
}
var j2 = min(k, n)
while j2 < n && bytes[j2] != gt { j2 += 1 }
i = min(n, j2 + 1)
textStart = i
closeTag(tag)
}
} else {
i += 1 // stray '<' in text
}
}
flushText(upTo: n)
}
// MARK: - Text helpers
private static func normalizedID(_ attrs: [String: String]) -> String? {
guard let id = attrs["id"]?.trimmingCharacters(in: .whitespaces), !id.isEmpty else { return nil }
return id
}
private static func splitClasses(_ value: String?) -> [String] {
guard let value else { return [] }
return value.split(whereSeparator: { $0.isWhitespace }).map(String.init)
}
private static func selectorString(tag: String, id: String?, classes: [String]) -> String {
var s = tag
if let id, !id.isEmpty { s += "#\(id)" }
for c in classes { s += ".\(c)" }
return s
}
private static func collapseWhitespace(_ s: String) -> String {
s.split(whereSeparator: { $0.isWhitespace }).joined(separator: " ")
}
private static func decodeEntities(_ s: String) -> String {
guard s.contains("&") else { return s }
var result = ""
result.reserveCapacity(s.count)
var i = s.startIndex
while i < s.endIndex {
if s[i] == "&" {
var j = s.index(after: i)
var entity = ""
var terminated = false
var steps = 0
while j < s.endIndex, steps < 12 {
if s[j] == ";" { terminated = true; break }
entity.append(s[j])
j = s.index(after: j)
steps += 1
}
if terminated, let decoded = decodeEntity(entity) {
result.append(decoded)
i = s.index(after: j)
continue
}
}
result.append(s[i])
i = s.index(after: i)
}
return result
}
private static func decodeEntity(_ e: String) -> Character? {
switch e {
case "amp": return "&"
case "lt": return "<"
case "gt": return ">"
case "quot": return "\""
case "apos": return "'"
case "nbsp": return " "
case "ndash": return ""
case "mdash": return ""
case "hellip": return ""
default:
if e.hasPrefix("#x") || e.hasPrefix("#X"),
let v = UInt32(e.dropFirst(2), radix: 16), let u = UnicodeScalar(v) {
return Character(u)
}
if e.hasPrefix("#"), let v = UInt32(e.dropFirst()), let u = UnicodeScalar(v) {
return Character(u)
}
return nil
}
}
}

View File

@ -0,0 +1,148 @@
import AppKit
/// A line-number gutter for an NSTextView, themed to match the editor.
/// Highlights the line containing the insertion point.
final class LineNumberRulerView: NSRulerView {
private weak var textView: NSTextView?
private var theme: EditorTheme = .system
init(textView: NSTextView) {
self.textView = textView
super.init(scrollView: textView.enclosingScrollView, orientation: .verticalRuler)
clientView = textView
ruleThickness = 44
let center = NotificationCenter.default
center.addObserver(self, selector: #selector(needsRedraw),
name: NSText.didChangeNotification, object: textView)
center.addObserver(self, selector: #selector(needsRedraw),
name: NSTextView.didChangeSelectionNotification, object: textView)
if let clip = textView.enclosingScrollView?.contentView {
clip.postsBoundsChangedNotifications = true
center.addObserver(self, selector: #selector(needsRedraw),
name: NSView.boundsDidChangeNotification, object: clip)
}
}
required init(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
deinit { NotificationCenter.default.removeObserver(self) }
func apply(theme: EditorTheme) {
self.theme = theme
needsDisplay = true
}
/// Point the gutter at a different text view (used when the editor switches
/// which board page it is showing).
func retarget(to tv: NSTextView) {
NotificationCenter.default.removeObserver(self)
textView = tv
clientView = tv
let center = NotificationCenter.default
center.addObserver(self, selector: #selector(needsRedraw),
name: NSText.didChangeNotification, object: tv)
center.addObserver(self, selector: #selector(needsRedraw),
name: NSTextView.didChangeSelectionNotification, object: tv)
if let clip = tv.enclosingScrollView?.contentView {
clip.postsBoundsChangedNotifications = true
center.addObserver(self, selector: #selector(needsRedraw),
name: NSView.boundsDidChangeNotification, object: clip)
}
needsDisplay = true
}
@objc private func needsRedraw() { needsDisplay = true }
/// Widen the gutter as the line count grows so 4- and 5-digit files fit.
func updateThickness() {
guard let lineCount = textView?.string.reduce(into: 1, { c, ch in if ch == "\n" { c += 1 } }) else { return }
let digits = max(2, String(lineCount).count)
let font = gutterFont
let sample = String(repeating: "8", count: digits)
let width = (sample as NSString).size(withAttributes: [.font: font]).width
let thickness = ceil(width) + 16
if abs(thickness - ruleThickness) > 0.5 { ruleThickness = thickness }
}
private var gutterFont: NSFont {
let base = (textView?.font?.pointSize ?? 13) - 2
return NSFont.monospacedDigitSystemFont(ofSize: max(9, base), weight: .regular)
}
override func drawHashMarksAndLabels(in rect: NSRect) {
guard let textView,
let layoutManager = textView.layoutManager,
let container = textView.textContainer,
let scrollView = scrollView else { return }
theme.gutterBackground.setFill()
bounds.fill()
// Subtle separator line on the gutter's trailing edge.
theme.gutterText.withAlphaComponent(0.25).setStroke()
let sep = NSBezierPath()
sep.move(to: NSPoint(x: bounds.maxX - 0.5, y: bounds.minY))
sep.line(to: NSPoint(x: bounds.maxX - 0.5, y: bounds.maxY))
sep.lineWidth = 1
sep.stroke()
let content = scrollView.contentView
let visibleRect = content.bounds
let inset = textView.textContainerInset
let nsString = textView.string as NSString
let font = gutterFont
let normalAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.gutterText]
let activeAttrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: theme.accent]
// Which line holds the insertion point?
let selectedLine = currentLineNumber(in: nsString, selection: textView.selectedRange())
let glyphRange = layoutManager.glyphRange(forBoundingRect: visibleRect, in: container)
let charRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
var lineNumber = lineCount(in: nsString, upTo: charRange.location)
var index = charRange.location
while index <= NSMaxRange(charRange) {
let lineRange = nsString.lineRange(for: NSRange(location: index, length: 0))
let glyphLineRange = layoutManager.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil)
var lineRect = layoutManager.boundingRect(forGlyphRange: glyphLineRange, in: container)
lineRect.origin.y += inset.height
// Convert text Y into ruler Y.
let y = lineRect.minY - visibleRect.minY
let isActive = lineNumber == selectedLine
if isActive {
theme.currentLine.setFill()
NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill()
}
let label = "\(lineNumber)" as NSString
let attrs = isActive ? activeAttrs : normalAttrs
let size = label.size(withAttributes: attrs)
let drawY = y + (lineRect.height - size.height) / 2
label.draw(at: NSPoint(x: bounds.width - size.width - 8, y: drawY), withAttributes: attrs)
if NSMaxRange(lineRange) <= index { break }
index = NSMaxRange(lineRange)
lineNumber += 1
if index > nsString.length { break }
}
}
private func lineCount(in s: NSString, upTo location: Int) -> Int {
var count = 1
var i = 0
let end = min(location, s.length)
while i < end {
if s.character(at: i) == 10 { count += 1 }
i += 1
}
return count
}
private func currentLineNumber(in s: NSString, selection: NSRange) -> Int {
lineCount(in: s, upTo: selection.location)
}
}

292
Sources/Markdown.swift Normal file
View File

@ -0,0 +1,292 @@
import AppKit
/// A pragmatic Markdown NSAttributedString renderer for the live preview.
/// Handles the constructs people actually type: ATX headings, bold/italic/
/// bold-italic, inline code, fenced & indented code, blockquotes, ordered and
/// unordered lists, horizontal rules, and links. Colors come from the theme.
enum MarkdownRenderer {
static func render(_ markdown: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
let out = NSMutableAttributedString()
let lines = markdown.components(separatedBy: "\n")
var i = 0
var inFence = false
var fenceBuffer: [String] = []
func flushFence() {
let code = fenceBuffer.joined(separator: "\n")
out.append(codeBlock(code, theme: theme, baseSize: baseSize))
fenceBuffer = []
}
while i < lines.count {
let line = lines[i]
let trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") {
if inFence { flushFence(); inFence = false } else { inFence = true }
i += 1
continue
}
if inFence { fenceBuffer.append(line); i += 1; continue }
// Horizontal rule
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
out.append(horizontalRule(theme: theme, baseSize: baseSize))
i += 1
continue
}
// ATX heading
if let (level, content) = heading(trimmed) {
out.append(headingLine(content, level: level, theme: theme, baseSize: baseSize))
i += 1
continue
}
// Blockquote
if trimmed.hasPrefix(">") {
let content = String(trimmed.dropFirst()).trimmingCharacters(in: .whitespaces)
out.append(blockquote(content, theme: theme, baseSize: baseSize))
i += 1
continue
}
// Unordered list item
if let marker = unorderedMarker(trimmed) {
let content = String(trimmed.dropFirst(marker.count)).trimmingCharacters(in: .whitespaces)
out.append(listItem(bullet: "", content: content, theme: theme, baseSize: baseSize))
i += 1
continue
}
// Ordered list item
if let (number, content) = orderedItem(trimmed) {
out.append(listItem(bullet: "\(number). ", content: content, theme: theme, baseSize: baseSize))
i += 1
continue
}
// Blank line paragraph spacing
if trimmed.isEmpty {
out.append(NSAttributedString(string: "\n", attributes: [.font: bodyFont(baseSize)]))
i += 1
continue
}
// Paragraph
let para = inline(trimmed, theme: theme, baseSize: baseSize, font: bodyFont(baseSize))
out.append(para)
out.append(NSAttributedString(string: "\n", attributes: [.font: bodyFont(baseSize)]))
i += 1
}
if inFence { flushFence() }
// Every builder already sets its own foreground color, so there's no
// blanket recolor here that would clobber links, code, and accents.
return out
}
// MARK: - Block builders
private static func bodyFont(_ size: CGFloat) -> NSFont { NSFont.systemFont(ofSize: size) }
private static func monoFont(_ size: CGFloat) -> NSFont { NSFont.monospacedSystemFont(ofSize: size, weight: .regular) }
private static func heading(_ s: String) -> (Int, String)? {
var level = 0
for ch in s {
if ch == "#" { level += 1 } else { break }
}
guard level >= 1, level <= 6 else { return nil }
let rest = s.dropFirst(level)
guard rest.first == " " || rest.isEmpty else { return nil }
return (level, rest.trimmingCharacters(in: .whitespaces))
}
private static func unorderedMarker(_ s: String) -> String? {
for m in ["- ", "* ", "+ "] where s.hasPrefix(m) { return String(m.dropLast()) }
return nil
}
private static func orderedItem(_ s: String) -> (Int, String)? {
var digits = ""
for ch in s {
if ch.isNumber { digits.append(ch) } else { break }
}
guard let n = Int(digits) else { return nil }
let rest = s.dropFirst(digits.count)
guard rest.first == "." || rest.first == ")" else { return nil }
let content = rest.dropFirst().trimmingCharacters(in: .whitespaces)
return (n, content)
}
private static func headingLine(_ content: String, level: Int, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
let scale: CGFloat = [1.9, 1.55, 1.3, 1.15, 1.05, 1.0][min(level - 1, 5)]
let weight: NSFont.Weight = level <= 2 ? .bold : .semibold
let font = NSFont.systemFont(ofSize: round(baseSize * scale), weight: weight)
let para = NSMutableParagraphStyle()
para.paragraphSpacingBefore = baseSize * 0.6
para.paragraphSpacing = baseSize * 0.3
let attr = inline(content, theme: theme, baseSize: baseSize, font: font)
let m = NSMutableAttributedString(attributedString: attr)
m.addAttributes([.paragraphStyle: para], range: NSRange(location: 0, length: m.length))
m.append(NSAttributedString(string: "\n"))
return m
}
private static func codeBlock(_ code: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
let para = NSMutableParagraphStyle()
para.firstLineHeadIndent = 12
para.headIndent = 12
para.paragraphSpacingBefore = baseSize * 0.3
para.paragraphSpacing = baseSize * 0.3
let bg = theme.isDark ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.05)
let attrs: [NSAttributedString.Key: Any] = [
.font: monoFont(baseSize - 1),
.foregroundColor: theme.foreground,
.backgroundColor: bg,
.paragraphStyle: para,
]
return NSAttributedString(string: code + "\n", attributes: attrs)
}
private static func blockquote(_ content: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
let para = NSMutableParagraphStyle()
para.firstLineHeadIndent = 14
para.headIndent = 14
para.paragraphSpacingBefore = 2
para.paragraphSpacing = 2
let attr = inline(content, theme: theme, baseSize: baseSize,
font: NSFontManager.shared.convert(bodyFont(baseSize), toHaveTrait: .italicFontMask))
let m = NSMutableAttributedString(attributedString: attr)
m.addAttributes([.paragraphStyle: para, .foregroundColor: theme.secondaryText],
range: NSRange(location: 0, length: m.length))
m.append(NSAttributedString(string: "\n"))
return m
}
private static func listItem(bullet: String, content: String, theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
let para = NSMutableParagraphStyle()
para.firstLineHeadIndent = 16
para.headIndent = 16 + (bullet as NSString).size(withAttributes: [.font: bodyFont(baseSize)]).width
let m = NSMutableAttributedString()
m.append(NSAttributedString(string: bullet, attributes: [
.font: bodyFont(baseSize), .foregroundColor: theme.accent, .paragraphStyle: para,
]))
let body = inline(content, theme: theme, baseSize: baseSize, font: bodyFont(baseSize))
let bodyM = NSMutableAttributedString(attributedString: body)
bodyM.addAttribute(.paragraphStyle, value: para, range: NSRange(location: 0, length: bodyM.length))
m.append(bodyM)
m.append(NSAttributedString(string: "\n"))
return m
}
private static func horizontalRule(theme: EditorTheme, baseSize: CGFloat) -> NSAttributedString {
let para = NSMutableParagraphStyle()
para.paragraphSpacingBefore = baseSize * 0.4
para.paragraphSpacing = baseSize * 0.4
return NSAttributedString(string: "________________________\n", attributes: [
.font: bodyFont(baseSize),
.foregroundColor: theme.secondaryText.withAlphaComponent(0.4),
.paragraphStyle: para,
])
}
// MARK: - Inline parsing
/// Parses bold/italic/code/links inside a single line. `font` is the base
/// font for this block (so headings keep their size while going bold etc).
private static func inline(_ text: String, theme: EditorTheme, baseSize: CGFloat, font: NSFont) -> NSAttributedString {
let result = NSMutableAttributedString()
let chars = Array(text)
var i = 0
var plainStart = 0
func flushPlain(upTo end: Int) {
if end > plainStart {
let s = String(chars[plainStart..<end])
result.append(NSAttributedString(string: s, attributes: [.font: font, .foregroundColor: theme.foreground]))
}
}
func emphasisFont(bold: Bool, italic: Bool) -> NSFont {
var f = font
let mgr = NSFontManager.shared
if bold { f = mgr.convert(f, toHaveTrait: .boldFontMask) }
if italic { f = mgr.convert(f, toHaveTrait: .italicFontMask) }
return f
}
func matchRun(_ marker: Character, count: Int, from start: Int) -> Int? {
// Find a closing run of `count` markers after start.
var j = start
while j < chars.count {
if chars[j] == marker {
var run = 0
while j + run < chars.count && chars[j + run] == marker { run += 1 }
if run >= count { return j }
j += run
} else {
j += 1
}
}
return nil
}
while i < chars.count {
let c = chars[i]
// Inline code `...`
if c == "`" {
if let close = chars[(i + 1)...].firstIndex(of: "`") {
flushPlain(upTo: i)
let codeText = String(chars[(i + 1)..<close])
let bg = theme.isDark ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06)
result.append(NSAttributedString(string: codeText, attributes: [
.font: monoFont(baseSize - 1), .foregroundColor: theme.accent, .backgroundColor: bg,
]))
i = close + 1
plainStart = i
continue
}
}
// Bold/italic ** * __ _
if c == "*" || c == "_" {
var run = 0
while i + run < chars.count && chars[i + run] == c { run += 1 }
let count = min(run, 3)
if let close = matchRun(c, count: count, from: i + count) {
flushPlain(upTo: i)
let innerText = String(chars[(i + count)..<close])
let bold = count >= 2
let italic = count == 1 || count == 3
let inner = inline(innerText, theme: theme, baseSize: baseSize, font: emphasisFont(bold: bold, italic: italic))
result.append(inner)
i = close + count
plainStart = i
continue
}
}
// Link [text](url)
if c == "[" {
if let closeBracket = chars[(i + 1)...].firstIndex(of: "]"),
closeBracket + 1 < chars.count, chars[closeBracket + 1] == "(",
let closeParen = chars[(closeBracket + 2)...].firstIndex(of: ")") {
flushPlain(upTo: i)
let label = String(chars[(i + 1)..<closeBracket])
let url = String(chars[(closeBracket + 2)..<closeParen])
var attrs: [NSAttributedString.Key: Any] = [
.font: font, .foregroundColor: theme.accent,
.underlineStyle: NSUnderlineStyle.single.rawValue,
]
if let u = URL(string: url) { attrs[.link] = u }
result.append(NSAttributedString(string: label, attributes: attrs))
i = closeParen + 1
plainStart = i
continue
}
}
i += 1
}
flushPlain(upTo: chars.count)
return result
}
}

428
Sources/PostgresStore.swift Normal file
View File

@ -0,0 +1,428 @@
import Foundation
import CryptoKit
enum PGSettings {
static let autoIngestKey = "pgAutoIngest"
static let uriKey = "pgURI"
static let defaultURI = "postgresql://localhost:5433/scraperrr"
static var autoIngest: Bool { UserDefaults.standard.bool(forKey: autoIngestKey) }
static var uri: String {
let v = (UserDefaults.standard.string(forKey: uriKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return v.isEmpty ? defaultURI : v
}
}
struct PGError: Error {
let message: String
}
/// Talks to Postgres by piping SQL into `psql` no client library to link.
/// All values are escaped as SQL literals; numeric ids are validated in Swift.
enum PostgresStore {
static var psqlPath: String? {
let candidates = [
"/opt/homebrew/opt/postgresql@17/bin/psql",
"/opt/homebrew/opt/postgresql@18/bin/psql",
"/opt/homebrew/opt/postgresql@16/bin/psql",
"/opt/homebrew/bin/psql",
"/usr/local/bin/psql",
"/usr/bin/psql",
]
return candidates.first { FileManager.default.isExecutableFile(atPath: $0) }
}
struct PageIngest {
let url: String?
let sourceName: String
let contentHash: String
let elementCount: Int
let selectors: [(selector: String, count: Int, sample: String)]
let profileName: String?
let header: [String]
let rows: [[String]]
}
struct IngestReport {
let pageId: Int64
let selectorCount: Int
let rowCount: Int
let snapshotCount: Int
let skippedRows: Int
let skippedDuplicate: Bool
}
static func sha256(_ s: String) -> String {
SHA256.hash(data: Data(s.utf8)).map { String(format: "%02x", $0) }.joined()
}
// MARK: - Public entry points (call from main; completions on main)
static func test(uri: String, completion: @escaping (Result<String, PGError>) -> Void) {
let sql = schemaSQL + "\nSELECT current_database() || ' · ' || split_part(version(), ' on ', 1);"
runSQL(uri: uri, sql: sql) { result in
switch result {
case .success(let out):
let last = out.split(separator: "\n").last.map(String.init) ?? "ok"
completion(.success("Connected, tables ready — \(last)"))
case .failure(let error):
completion(.failure(error))
}
}
}
static func ingest(_ page: PageIngest, force: Bool,
completion: @escaping (Result<IngestReport, PGError>) -> Void) {
let uri = PGSettings.uri
func runMainIngest() {
let built = buildIngestSQL(page)
runSQL(uri: uri, sql: built.sql) { result in
switch result {
case .success(let out):
guard let idLine = out.split(separator: "\n").last,
let pageId = Int64(idLine.trimmingCharacters(in: .whitespaces)) else {
completion(.failure(PGError(message: "Couldn't read the new page id from psql output: \(out.suffix(120))")))
return
}
completion(.success(IngestReport(pageId: pageId,
selectorCount: page.selectors.count,
rowCount: page.rows.count,
snapshotCount: built.snapshotCount,
skippedRows: built.skipped,
skippedDuplicate: false)))
case .failure(let error):
completion(.failure(error))
}
}
}
if force || page.url == nil {
runMainIngest()
return
}
// Auto mode: skip if the latest capture of this URL has identical content.
let checkSQL = schemaSQL + """
SELECT content_hash FROM tp_pages WHERE url = \(lit(page.url))
ORDER BY captured_at DESC LIMIT 1;
"""
runSQL(uri: uri, sql: checkSQL) { result in
switch result {
case .success(let out):
let lastHash = out.split(separator: "\n").last.map(String.init) ?? ""
if lastHash == page.contentHash {
completion(.success(IngestReport(pageId: -1, selectorCount: 0, rowCount: 0,
snapshotCount: 0, skippedRows: 0, skippedDuplicate: true)))
} else {
runMainIngest()
}
case .failure(let error):
completion(.failure(error))
}
}
}
// MARK: - SQL construction
private static let schemaSQL = """
CREATE TABLE IF NOT EXISTS tp_pages (
id BIGSERIAL PRIMARY KEY,
url TEXT,
source_name TEXT,
content_hash TEXT,
element_count INT,
selector_count INT,
captured_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS tp_selectors (
id BIGSERIAL PRIMARY KEY,
page_id BIGINT REFERENCES tp_pages(id) ON DELETE CASCADE,
selector TEXT NOT NULL,
match_count INT NOT NULL,
sample TEXT
);
CREATE TABLE IF NOT EXISTS tp_rows (
id BIGSERIAL PRIMARY KEY,
page_id BIGINT REFERENCES tp_pages(id) ON DELETE CASCADE,
profile TEXT,
row_num INT,
fields JSONB
);
CREATE INDEX IF NOT EXISTS idx_tp_pages_url ON tp_pages(url);
CREATE INDEX IF NOT EXISTS idx_tp_rows_page ON tp_rows(page_id);
CREATE TABLE IF NOT EXISTS discogs_sellers (
seller_id BIGINT PRIMARY KEY,
username VARCHAR(100) NOT NULL,
last_scraped_at TIMESTAMPTZ DEFAULT now()
);
-- A username can be recycled across seller_ids over time; the seller_id is
-- the real key. Drop the old UNIQUE(username) so a reused name can't abort
-- a whole page ingest. (IF NOT EXISTS skips the table on existing installs,
-- so the constraint must be dropped explicitly.)
ALTER TABLE discogs_sellers DROP CONSTRAINT IF EXISTS discogs_sellers_username_key;
CREATE TABLE IF NOT EXISTS marketplace_snapshots (
item_id BIGINT PRIMARY KEY,
release_id BIGINT NOT NULL,
seller_id BIGINT REFERENCES discogs_sellers(seller_id),
title TEXT,
listing_url TEXT,
media_condition VARCHAR(120),
sleeve_condition VARCHAR(120),
price_currency VARCHAR(10),
price_value NUMERIC(12,2),
shipping_value NUMERIC(12,2),
first_seen TIMESTAMPTZ DEFAULT now(),
last_seen TIMESTAMPTZ DEFAULT now(),
is_active BOOLEAN DEFAULT TRUE
);
CREATE INDEX IF NOT EXISTS idx_market_release_id ON marketplace_snapshots(release_id);
CREATE INDEX IF NOT EXISTS idx_market_seller_active ON marketplace_snapshots(seller_id) WHERE is_active = TRUE;
"""
private static func buildIngestSQL(_ page: PageIngest) -> (sql: String, snapshotCount: Int, skipped: Int) {
var sql = schemaSQL + "\nBEGIN;\n"
sql += """
INSERT INTO tp_pages (url, source_name, content_hash, element_count, selector_count)
VALUES (\(lit(page.url)), \(lit(page.sourceName)), \(lit(page.contentHash)), \(page.elementCount), \(page.selectors.count))
RETURNING id AS page_id \\gset
"""
for chunk in page.selectors.chunked(into: 500) {
let values = chunk.map {
"(:page_id, \(lit($0.selector)), \($0.count), \(lit(String($0.sample.prefix(300)))))"
}.joined(separator: ",\n")
sql += "INSERT INTO tp_selectors (page_id, selector, match_count, sample) VALUES\n\(values);\n"
}
if !page.rows.isEmpty, let profile = page.profileName {
for (chunkIndex, chunk) in page.rows.chunked(into: 200).enumerated() {
let values = chunk.enumerated().map { offset, row -> String in
let rowNum = chunkIndex * 200 + offset + 1
var dict: [String: String] = [:]
for (i, name) in page.header.enumerated() where i < row.count {
// Condition fields capture the grade plus its tooltip
// description; keep the JSONB mirror as clean as the
// snapshot column (grade through the closing paren).
if name.hasSuffix("_condition"), let close = row[i].range(of: ")") {
dict[name] = String(row[i][..<close.upperBound])
} else {
dict[name] = row[i]
}
}
let json = (try? JSONSerialization.data(withJSONObject: dict))
.flatMap { String(data: $0, encoding: .utf8) } ?? "{}"
return "(:page_id, \(lit(profile)), \(rowNum), \(lit(json))::jsonb)"
}.joined(separator: ",\n")
sql += "INSERT INTO tp_rows (page_id, profile, row_num, fields) VALUES\n\(values);\n"
}
}
var snapshotCount = 0
var skipped = 0
if page.profileName == ScrapeProfile.discogsMarketplace.name {
let upserts = buildDiscogsUpserts(header: page.header, rows: page.rows)
sql += upserts.sql
snapshotCount = upserts.snapshotCount
skipped = upserts.skipped
}
sql += "COMMIT;\nSELECT :page_id;\n"
return (sql, snapshotCount, skipped)
}
/// Maps profile rows onto the scraperrr snapshot schema:
/// sellers upserted by data-seller-id, listings upserted by data-item-id
/// with last_seen/price refreshed on re-capture.
private static func buildDiscogsUpserts(header: [String], rows: [[String]]) -> (sql: String, snapshotCount: Int, skipped: Int) {
func col(_ name: String) -> Int? { header.firstIndex(of: name) }
guard let itemIdx = col("item_id"), let relIdx = col("release_id") else { return ("", 0, 0) }
let sellerIdIdx = col("seller_id"), sellerNameIdx = col("seller_username")
let sellerNameAltIdx = col("seller_username_alt")
let titleIdx = col("title"), urlIdx = col("listing_url")
let mediaIdx = col("media_condition"), sleeveIdx = col("sleeve_condition")
let curIdx = col("price_currency"), priceIdx = col("price_value")
let shipIdx = col("shipping")
func value(_ row: [String], _ idx: Int?) -> String? {
guard let idx, idx < row.count else { return nil }
let v = row[idx].trimmingCharacters(in: .whitespaces)
return v.isEmpty ? nil : v
}
// "Near Mint (NM or M-) A nearly perfect record" keep through the ")".
func condition(_ s: String?) -> String? {
guard let s else { return nil }
if let close = s.range(of: ")") { return String(s[..<close.upperBound]) }
return String(s.prefix(60))
}
func numeric(_ s: String?) -> String? {
guard let s, let d = Double(s) else { return nil }
return String(format: "%.2f", d)
}
func firstDecimal(_ s: String?) -> String? {
guard let s, let r = s.range(of: #"\d+(\.\d+)?"#, options: .regularExpression) else { return nil }
return numeric(String(s[r]))
}
var sellers: [Int64: String] = [:]
var snapshots: [Int64: String] = [:] // item_id VALUES tuple (dedup within batch)
var skipped = 0
for row in rows {
guard let itemId = value(row, itemIdx).flatMap({ Int64($0) }),
let releaseId = value(row, relIdx).flatMap({ Int64($0) }) else {
skipped += 1
continue
}
// Only reference a seller_id the snapshot's FK can resolve: upsert
// the seller whenever its id is present (falling back to the
// co-located data-username, then a derived name), and NULL the
// snapshot's seller_id when no id was found.
var linkedSellerId: Int64?
if let sellerId = value(row, sellerIdIdx).flatMap({ Int64($0) }) {
let name = value(row, sellerNameIdx)
?? value(row, sellerNameAltIdx)
?? "seller_\(sellerId)"
sellers[sellerId] = name
linkedSellerId = sellerId
}
let tuple = """
(\(itemId), \(releaseId), \(linkedSellerId.map(String.init) ?? "NULL"), \
\(lit(value(row, titleIdx))), \(lit(value(row, urlIdx))), \
\(lit(condition(value(row, mediaIdx)))), \(lit(condition(value(row, sleeveIdx)))), \
\(lit(value(row, curIdx))), \(numeric(value(row, priceIdx)) ?? "NULL"), \
\(firstDecimal(value(row, shipIdx)) ?? "NULL"))
"""
snapshots[itemId] = tuple
}
guard !snapshots.isEmpty else { return ("", 0, skipped) }
var sql = ""
if !sellers.isEmpty {
let sellerValues = sellers
.sorted { $0.key < $1.key }
.map { "(\($0.key), \(lit(String($0.value.prefix(100)))))" }
.joined(separator: ",\n")
sql += """
INSERT INTO discogs_sellers (seller_id, username) VALUES
\(sellerValues)
ON CONFLICT (seller_id) DO UPDATE SET username = EXCLUDED.username, last_scraped_at = now();
"""
}
let snapshotValues = snapshots.sorted { $0.key < $1.key }.map { $0.value }.joined(separator: ",\n")
sql += """
INSERT INTO marketplace_snapshots
(item_id, release_id, seller_id, title, listing_url, media_condition,
sleeve_condition, price_currency, price_value, shipping_value) VALUES
\(snapshotValues)
ON CONFLICT (item_id) DO UPDATE SET
last_seen = now(),
price_value = EXCLUDED.price_value,
shipping_value = EXCLUDED.shipping_value,
media_condition = EXCLUDED.media_condition,
sleeve_condition = EXCLUDED.sleeve_condition,
is_active = TRUE;
"""
return (sql, snapshots.count, skipped)
}
private static func lit(_ s: String?) -> String {
guard let s else { return "NULL" }
let cleaned = s.replacingOccurrences(of: "\0", with: "")
return "'" + cleaned.replacingOccurrences(of: "'", with: "''") + "'"
}
// MARK: - psql runner
private static let workQueue = DispatchQueue(label: "textplus.postgres")
private static let timeoutSeconds: TimeInterval = 60
private static func runSQL(uri: String, sql: String,
completion: @escaping (Result<String, PGError>) -> Void) {
guard let psql = psqlPath else {
DispatchQueue.main.async {
completion(.failure(PGError(message: "psql not found — install with: brew install postgresql@17")))
}
return
}
workQueue.async {
let process = Process()
process.executableURL = URL(fileURLWithPath: psql)
process.arguments = ["-X", "-q", "-A", "-t", "-v", "ON_ERROR_STOP=1", uri]
let inPipe = Pipe(), outPipe = Pipe(), errPipe = Pipe()
process.standardInput = inPipe
process.standardOutput = outPipe
process.standardError = errPipe
do {
try process.run()
} catch {
DispatchQueue.main.async { completion(.failure(PGError(message: error.localizedDescription))) }
return
}
let watchdog = DispatchWorkItem { [weak process] in
guard let process, process.isRunning else { return }
process.terminate()
}
DispatchQueue.global().asyncAfter(deadline: .now() + timeoutSeconds, execute: watchdog)
// Drain stdout/stderr concurrently while we feed stdin, so a large
// script can't deadlock on a full pipe in either direction.
var outData = Data()
var errData = Data()
let group = DispatchGroup()
group.enter()
DispatchQueue.global(qos: .utility).async {
outData = outPipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
group.enter()
DispatchQueue.global(qos: .utility).async {
errData = errPipe.fileHandleForReading.readDataToEndOfFile()
group.leave()
}
let writer = inPipe.fileHandleForWriting
do {
try writer.write(contentsOf: Data(sql.utf8))
try writer.close()
} catch {
// Broken pipe (psql died early) fall through to read its stderr.
try? writer.close()
}
group.wait()
process.waitUntilExit()
watchdog.cancel()
let out = String(decoding: outData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
let err = String(decoding: errData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
let status = process.terminationStatus
DispatchQueue.main.async {
if status == 0 {
completion(.success(out))
} else {
let detail = err.isEmpty ? "psql exited with status \(status)" : err
completion(.failure(PGError(message: detail)))
}
}
}
}
}
private extension Array {
func chunked(into size: Int) -> [[Element]] {
stride(from: 0, to: count, by: size).map { Array(self[$0..<Swift.min($0 + size, count)]) }
}
}

View File

@ -0,0 +1,70 @@
import Foundation
/// A named "row + fields" extraction recipe, auto-applied when a page's URL
/// matches. The Discogs profile mirrors the selectors in the scraperrr notes.
struct ScrapeProfile {
let name: String
let urlPattern: String
let rowSelector: String
let fields: [(name: String, selector: String)]
func matchesURL(_ url: String) -> Bool {
url.range(of: urlPattern, options: [.regularExpression, .caseInsensitive]) != nil
}
static let discogsMarketplace = ScrapeProfile(
name: "discogs_marketplace",
urlPattern: #"discogs\.com/(sell/list|sell/mywants|seller/[^/]+/profile)"#,
rowSelector: "tr.shortcut_navigable",
fields: [
("item_id", "a.cart-button@data-item-id"),
("release_id", "tr@data-release-id"),
("title", "a.item_description_title"),
("listing_url", "a.item_description_title@href"),
("media_condition", ".item_condition span[!class]"),
("sleeve_condition", "span.item_sleeve_condition"),
("seller_username", ".seller_block a"),
("seller_id", "li.seller_mywants@data-seller-id"),
// Co-located with seller_id on the same element a reliable
// username fallback so a seller is never referenced unmapped.
("seller_username_alt", "li.seller_mywants@data-username"),
("price_value", "span.price@data-pricevalue"),
("price_currency", "span.price@data-currency"),
("shipping", "span.item_shipping"),
]
)
static let builtIn: [ScrapeProfile] = [discogsMarketplace]
static func matching(url: String?) -> ScrapeProfile? {
guard let url else { return nil }
return builtIn.first { $0.matchesURL(url) }
}
}
struct DetectedPage {
let url: String?
let html: String
}
enum PageDetector {
/// Recognizes the "URL on the first line, then pasted HTML" convention
/// (URL optional). Returns nil when the document doesn't look like HTML.
static func detect(in text: String) -> DetectedPage? {
var url: String?
var html = String(text.drop(while: { $0.isWhitespace }))
if html.hasPrefix("http://") || html.hasPrefix("https://") {
if let newline = html.firstIndex(of: "\n") {
let candidate = String(html[..<newline]).trimmingCharacters(in: .whitespaces)
if !candidate.contains(" "), !candidate.contains("<") {
url = candidate
html = String(html[newline...])
}
}
}
let htmlish = html.contains("<body") || html.contains("<html") || html.contains("<div")
|| html.contains("<table") || html.drop(while: { $0.isWhitespace }).hasPrefix("<")
guard htmlish else { return nil }
return DetectedPage(url: url, html: html)
}
}

170
Sources/SettingsView.swift Normal file
View File

@ -0,0 +1,170 @@
import SwiftUI
struct SettingsView: View {
@AppStorage(SyncSettings.enabledKey) private var enabled = false
@AppStorage(SyncSettings.remoteKey) private var remote = ""
@AppStorage(SyncSettings.dirKey) private var dir = ""
// Stored as text so edits commit per keystroke (a format-based numeric
// field only commits on Return/focus loss, which a button click isn't).
@AppStorage(SyncSettings.portKey) private var portText = "22"
@AppStorage(PGSettings.autoIngestKey) private var pgAutoIngest = false
@AppStorage(PGSettings.uriKey) private var pgURI = PGSettings.defaultURI
@AppStorage(AppSettings.boardPageSizeKey) private var boardPageSize = "letter"
@AppStorage(AppSettings.boardOrientationKey) private var boardOrientation = "portrait"
@AppStorage(AppSettings.boardSnapKey) private var boardSnap = true
@State private var testing = false
@State private var testMessage: String?
@State private var testSucceeded = false
// Bumped on every config edit so an in-flight test of the old config
// can't land its result on the new one.
@State private var testGeneration = 0
@State private var pgTesting = false
@State private var pgMessage: String?
@State private var pgSucceeded = false
private var trimmedRemote: String { remote.trimmingCharacters(in: .whitespaces) }
private var portValue: Int? {
let t = portText.trimmingCharacters(in: .whitespaces)
if t.isEmpty { return 22 }
guard let p = Int(t), (1...65535).contains(p) else { return nil }
return p
}
var body: some View {
Form {
Section("Board") {
Picker("New page size:", selection: $boardPageSize) {
ForEach(PageSize.allCases, id: \.rawValue) { ps in
Text(ps.label).tag(ps.rawValue)
}
}
Picker("New page orientation:", selection: $boardOrientation) {
Text("Portrait").tag("portrait")
Text("Landscape").tag("landscape")
}
Toggle("Snap to grid while dragging", isOn: $boardSnap)
Text("Defaults for new documents and new text pages. Change the current page's size anytime with the size popup and P/L control in Board mode.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Tailnet sync") {
Toggle("Copy file to remote Mac on every save", isOn: $enabled)
if enabled && trimmedRemote.isEmpty {
Label("Sync is on, but no remote is set below — saves won't be copied",
systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
}
}
Section("Remote (over Tailscale)") {
TextField("Remote:", text: $remote, prompt: Text("user@tailscale-hostname"))
.autocorrectionDisabled()
TextField("Folder:", text: $dir, prompt: Text("/Users/you/Documents/inbox or ~/inbox"))
.autocorrectionDisabled()
TextField("SSH port:", text: $portText, prompt: Text("22"))
if portValue == nil {
Label("Port must be a number from 165535",
systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
}
}
Section {
HStack(spacing: 10) {
Button(testing ? "Testing…" : "Test Connection") { runTest() }
.disabled(testing || trimmedRemote.isEmpty || portValue == nil)
if testing {
ProgressView().controlSize(.small)
} else if let message = testMessage {
Label(message, systemImage: testSucceeded ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(testSucceeded ? Color.green : Color.red)
.lineLimit(3)
.textSelection(.enabled)
}
}
Text("Uses your SSH keys (no password prompts). `ssh \(trimmedRemote.isEmpty ? "user@host" : trimmedRemote)` must work without typing a password — see the README for one-time key setup. The target Mac needs Remote Login enabled in System Settings → Sharing.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Postgres (scraper ingest)") {
Toggle("Auto-ingest HTML pages on save", isOn: $pgAutoIngest)
TextField("Connection URI:", text: $pgURI, prompt: Text(PGSettings.defaultURI))
.autocorrectionDisabled()
HStack(spacing: 10) {
Button(pgTesting ? "Testing…" : "Test & Create Tables") { runPGTest() }
.disabled(pgTesting)
if pgTesting {
ProgressView().controlSize(.small)
} else if let message = pgMessage {
Label(message, systemImage: pgSucceeded ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundStyle(pgSucceeded ? Color.green : Color.red)
.lineLimit(3)
.textSelection(.enabled)
}
}
Text(pgFooter)
.font(.caption)
.foregroundStyle(.secondary)
}
}
.formStyle(.grouped)
.frame(width: 480)
.fixedSize(horizontal: false, vertical: true)
.onChange(of: remote) { invalidateTest() }
.onChange(of: dir) { invalidateTest() }
.onChange(of: portText) { invalidateTest() }
}
private var pgFooter: String {
let psql = PostgresStore.psqlPath.map { "psql: \($0)" }
?? "psql not found — brew install postgresql@17"
return "On save, documents that look like HTML (URL on the first line, then pasted <body>) are parsed and stored: page + selector summary, and Discogs marketplace pages also upsert discogs_sellers / marketplace_snapshots. \(psql)"
}
private func runPGTest() {
pgTesting = true
pgMessage = nil
let uri = pgURI.trimmingCharacters(in: .whitespaces)
PostgresStore.test(uri: uri.isEmpty ? PGSettings.defaultURI : uri) { result in
pgTesting = false
switch result {
case .success(let message):
pgSucceeded = true
pgMessage = message
case .failure(let error):
pgSucceeded = false
pgMessage = error.message
}
}
}
private func invalidateTest() {
testGeneration += 1
testMessage = nil
testing = false
}
private func runTest() {
testGeneration += 1
let generation = testGeneration
testing = true
testMessage = nil
SyncEngine.testConnection(remote: trimmedRemote,
port: portValue ?? 22,
dir: dir) { result in
guard generation == testGeneration else { return }
testing = false
switch result {
case .success(let message):
testSucceeded = true
testMessage = message
case .failure(let error):
testSucceeded = false
testMessage = error.message
}
}
}
}

209
Sources/SyncEngine.swift Normal file
View File

@ -0,0 +1,209 @@
import Foundation
enum SyncSettings {
static let enabledKey = "syncEnabled"
static let remoteKey = "syncRemote"
static let dirKey = "syncRemoteDir"
static let portKey = "syncPort"
static var enabled: Bool { UserDefaults.standard.bool(forKey: enabledKey) }
static var remote: String {
(UserDefaults.standard.string(forKey: remoteKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static var dir: String {
(UserDefaults.standard.string(forKey: dirKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
}
static var port: Int {
let raw = (UserDefaults.standard.string(forKey: portKey) ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard let p = Int(raw), (1...65535).contains(p) else { return 22 }
return p
}
static var isConfigured: Bool { !remote.isEmpty }
}
struct SyncError: Error {
let message: String
}
enum SyncEngine {
/// Main-thread-only count of scp uploads that haven't finished yet.
private(set) static var inFlightUploads = 0
/// Called on the main thread when the last in-flight upload finishes.
static var onIdle: (() -> Void)?
private static let sshOptions = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=10",
"-o", "ServerAliveInterval=15",
"-o", "ServerAliveCountMax=3",
"-o", "StrictHostKeyChecking=accept-new",
]
private static let watchdogSeconds: TimeInterval = 120
// All invocations run serially: rapid saves of the same file must never
// have two scp processes writing the same remote path at once.
private static let workQueue = DispatchQueue(label: "textplus.sync")
/// Copy the file to the configured remote directory in the background.
/// Call from the main thread; the completion handler runs on the main queue.
static func upload(fileURL: URL, completion: @escaping (Result<Void, SyncError>) -> Void) {
dispatchPrecondition(condition: .onQueue(.main))
inFlightUploads += 1
let destination = scpDestination(remote: SyncSettings.remote, dir: SyncSettings.dir)
let args = sshOptions + ["-P", String(SyncSettings.port), fileURL.path, destination]
run(tool: "/usr/bin/scp", args: args) { status, output, timedOut in
if status == 0 && !timedOut {
completion(.success(()))
} else {
completion(.failure(SyncError(message: friendlyMessage(tool: "scp", status: status, output: output, timedOut: timedOut))))
}
inFlightUploads -= 1
if inFlightUploads == 0 { onIdle?() }
}
}
/// Check that we can reach the remote and that the directory exists.
static func testConnection(remote: String, port: Int, dir: String,
completion: @escaping (Result<String, SyncError>) -> Void) {
let trimmedDir = dir.trimmingCharacters(in: .whitespacesAndNewlines)
let probe = trimmedDir.isEmpty
? "echo __OK__"
: "if [ -d \(probePath(trimmedDir)) ]; then echo __OK__; else echo __NODIR__; fi"
let args = sshOptions + ["-p", String(port), remote, probe]
run(tool: "/usr/bin/ssh", args: args) { status, output, timedOut in
if output.contains("__OK__") {
completion(.success("Connected — folder exists"))
} else if output.contains("__NODIR__") {
completion(.failure(SyncError(message: "Connected, but the remote folder doesn't exist")))
} else {
completion(.failure(SyncError(message: friendlyMessage(tool: "ssh", status: status, output: output, timedOut: timedOut))))
}
}
}
// MARK: - Destination / path handling
/// scp splits the remote spec at the first colon, so IPv6 literals
/// (and other hosts containing ':') must be bracketed.
private static func scpDestination(remote: String, dir: String) -> String {
var d = dir
if !d.isEmpty && !d.hasSuffix("/") { d += "/" }
var user = ""
var host = remote
if let at = remote.lastIndex(of: "@") {
user = String(remote[...at])
host = String(remote[remote.index(after: at)...])
}
if host.contains(":") && !host.hasPrefix("[") {
host = "[\(host)]"
}
return "\(user)\(host):\(d)"
}
/// Quote the directory for the remote shell, but leave a leading ~
/// unquoted so it expands matching scp's SFTP-mode ~ expansion on upload.
private static func probePath(_ dir: String) -> String {
if dir == "~" { return "~" }
if dir.hasPrefix("~/") { return "~/" + shellQuoted(String(dir.dropFirst(2))) }
return shellQuoted(dir)
}
private static func shellQuoted(_ s: String) -> String {
"'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
// MARK: - Error reporting
private static func friendlyMessage(tool: String, status: Int32, output: String, timedOut: Bool) -> String {
if timedOut {
return "Timed out after \(Int(watchdogSeconds)) seconds"
}
var message = output.isEmpty ? "\(tool) exited with status \(status)" : output
if message.contains("Permission denied") {
// The window subtitle shows only the first line, so the hint goes there.
let lines = message.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
let hint = " — SSH key not available? Run 'ssh-add --apple-load-keychain' in Terminal,"
+ " or add 'UseKeychain yes' and 'AddKeysToAgent yes' to ~/.ssh/config"
message = (lines.first ?? message) + hint
if lines.count > 1 {
message += "\n" + lines.dropFirst().joined(separator: "\n")
}
}
return message
}
// MARK: - Process plumbing
private static func run(tool: String, args: [String],
completion: @escaping (Int32, String, Bool) -> Void) {
workQueue.async {
let process = Process()
process.executableURL = URL(fileURLWithPath: tool)
process.arguments = args
let outPipe = Pipe()
let errPipe = Pipe()
process.standardOutput = outPipe
process.standardError = errPipe
process.standardInput = FileHandle.nullDevice
do {
try process.run()
} catch {
DispatchQueue.main.async { completion(-1, error.localizedDescription, false) }
return
}
// Watchdog: unstick a wedged transfer. Weak capture so the canceled
// work item doesn't pin the Process and pipe FDs until its deadline.
let timedOutFlag = AtomicFlag()
let watchdog = DispatchWorkItem { [weak process] in
guard let process, process.isRunning else { return }
timedOutFlag.set()
process.terminate()
}
DispatchQueue.global().asyncAfter(deadline: .now() + watchdogSeconds, execute: watchdog)
// Drain both pipes concurrently so a full stderr buffer can't
// stall the child while we block on stdout.
var errData = Data()
let errDone = DispatchSemaphore(value: 0)
DispatchQueue.global(qos: .utility).async {
errData = errPipe.fileHandleForReading.readDataToEndOfFile()
errDone.signal()
}
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
errDone.wait()
process.waitUntilExit()
watchdog.cancel()
let combined = (String(decoding: outData, as: UTF8.self) + "\n"
+ String(decoding: errData, as: UTF8.self))
.trimmingCharacters(in: .whitespacesAndNewlines)
let status = process.terminationStatus
let timedOut = timedOutFlag.get()
DispatchQueue.main.async { completion(status, combined, timedOut) }
}
}
}
private final class AtomicFlag {
private let lock = NSLock()
private var value = false
func set() {
lock.lock()
value = true
lock.unlock()
}
func get() -> Bool {
lock.lock()
defer { lock.unlock() }
return value
}
}

39
Sources/TextMarkup.swift Normal file
View File

@ -0,0 +1,39 @@
import AppKit
/// Selection-aware Markdown insertion used by both the toolbar and the Format
/// menu. Wraps the selection (or inserts a placeholder), keeping the change on
/// the text view's own undo stack.
enum TextMarkup {
static func wrap(_ textView: NSTextView, prefix: String, suffix: String, placeholder: String) {
let range = textView.selectedRange()
let ns = textView.string as NSString
let selected = ns.substring(with: range)
let body = selected.isEmpty ? placeholder : selected
let replacement = prefix + body + suffix
guard textView.shouldChangeText(in: range, replacementString: replacement) else { return }
textView.replaceCharacters(in: range, with: replacement)
textView.didChangeText()
// Re-select the body so the user can keep typing or toggle again.
let bodyStart = range.location + (prefix as NSString).length
textView.setSelectedRange(NSRange(location: bodyStart, length: (body as NSString).length))
}
/// Prefix every line touched by the selection (headings, lists, quotes).
static func linePrefix(_ textView: NSTextView, prefix: String) {
let ns = textView.string as NSString
let lineRange = ns.lineRange(for: textView.selectedRange())
let block = ns.substring(with: lineRange)
let prefixed = block
.components(separatedBy: "\n")
.enumerated()
.map { idx, line in
// Don't add a stray prefix to a trailing empty line from the split.
(line.isEmpty && idx > 0) ? line : prefix + line
}
.joined(separator: "\n")
guard textView.shouldChangeText(in: lineRange, replacementString: prefixed) else { return }
textView.replaceCharacters(in: lineRange, with: prefixed)
textView.didChangeText()
textView.setSelectedRange(NSRange(location: lineRange.location, length: (prefixed as NSString).length))
}
}

View File

@ -0,0 +1,564 @@
import AppKit
/// Owns a document window's chrome: the toolbar, the editor / split / canvas
/// mode switching, the Markdown preview, and the bottom status bar. One per
/// document window; created in Document.makeWindowControllers.
final class WorkspaceController: NSObject, NSToolbarDelegate, NSTextViewDelegate {
enum Mode: Int { case editor, split, board }
weak var document: Document?
let containerView = NSView()
let editorScrollView: NSScrollView
let textView: NSTextView // the document's main page (== the .txt)
private var editorTV: NSTextView // the text view currently shown in the editor
private weak var activeEditorCard: CardView? // which board page the editor shows
private let ruler: LineNumberRulerView
private let splitView = NSSplitView()
private let previewScroll = NSScrollView()
private let previewTextView = NSTextView()
let boardContainer = BoardContainerView(frame: .zero)
private let statusBar = NSView()
private let leftStatus = NSTextField(labelWithString: "")
private let centerStatus = NSTextField(labelWithString: "")
private let rightStatus = NSTextField(labelWithString: "")
private var modeSegment: NSSegmentedControl?
private(set) var mode: Mode = .editor
init(document: Document, editorScrollView: NSScrollView, textView: NSTextView) {
self.document = document
self.editorScrollView = editorScrollView
self.textView = textView
self.editorTV = textView
self.ruler = LineNumberRulerView(textView: textView)
super.init()
buildLayout()
hookBoard()
NotificationCenter.default.addObserver(self, selector: #selector(appearanceChanged),
name: AppSettings.didChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(textChanged),
name: NSText.didChangeNotification, object: textView)
NotificationCenter.default.addObserver(self, selector: #selector(selectionChanged),
name: NSTextView.didChangeSelectionNotification, object: textView)
applyAppearance()
recomputeMetrics()
refreshStatus()
}
deinit { NotificationCenter.default.removeObserver(self) }
// MARK: - Layout
private func buildLayout() {
editorScrollView.hasVerticalRuler = true
editorScrollView.rulersVisible = AppSettings.lineNumbers
editorScrollView.verticalRulerView = ruler
// Preview pane
previewScroll.hasVerticalScroller = true
previewScroll.borderType = .noBorder
previewScroll.documentView = previewTextView
previewTextView.isEditable = false
previewTextView.isSelectable = true
previewTextView.textContainerInset = NSSize(width: 16, height: 14)
previewTextView.autoresizingMask = [.width]
previewTextView.minSize = NSSize(width: 0, height: 0)
previewTextView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
previewTextView.isVerticallyResizable = true
previewTextView.isHorizontallyResizable = false
previewTextView.textContainer?.widthTracksTextView = true
splitView.isVertical = true
splitView.dividerStyle = .thin
splitView.translatesAutoresizingMaskIntoConstraints = false
splitView.addArrangedSubview(editorScrollView)
boardContainer.translatesAutoresizingMaskIntoConstraints = false
boardContainer.isHidden = true
statusBar.translatesAutoresizingMaskIntoConstraints = false
statusBar.wantsLayer = true
buildStatusBar()
containerView.addSubview(splitView)
containerView.addSubview(boardContainer)
containerView.addSubview(statusBar)
NSLayoutConstraint.activate([
statusBar.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
statusBar.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
statusBar.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
statusBar.heightAnchor.constraint(equalToConstant: 24),
splitView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
splitView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
splitView.topAnchor.constraint(equalTo: containerView.topAnchor),
splitView.bottomAnchor.constraint(equalTo: statusBar.topAnchor),
boardContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
boardContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
boardContainer.topAnchor.constraint(equalTo: containerView.topAnchor),
boardContainer.bottomAnchor.constraint(equalTo: statusBar.topAnchor),
])
}
private func buildStatusBar() {
for label in [leftStatus, centerStatus, rightStatus] {
label.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
label.translatesAutoresizingMaskIntoConstraints = false
statusBar.addSubview(label)
}
centerStatus.alignment = .center
rightStatus.alignment = .right
NSLayoutConstraint.activate([
leftStatus.leadingAnchor.constraint(equalTo: statusBar.leadingAnchor, constant: 12),
leftStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor),
centerStatus.centerXAnchor.constraint(equalTo: statusBar.centerXAnchor),
centerStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor),
rightStatus.trailingAnchor.constraint(equalTo: statusBar.trailingAnchor, constant: -12),
rightStatus.centerYAnchor.constraint(equalTo: statusBar.centerYAnchor),
])
}
private func hookBoard() {
boardContainer.document = document
boardContainer.onStatus = { [weak self] msg in self?.centerStatus.stringValue = msg }
boardContainer.onSendToTailnet = { [weak self] data in self?.document?.sendSketchToTailnet(pngData: data) }
boardContainer.boardView.onChange = { [weak self] in
self?.document?.updateChangeCount(.changeDone); self?.refreshStatus()
}
boardContainer.onStatusChange = { [weak self] in self?.refreshStatus() }
}
// MARK: - Mode switching
func setMode(_ newMode: Mode) {
let leavingBoard = (mode == .board && newMode != .board)
mode = newMode
modeSegment?.selectedSegment = newMode.rawValue
switch newMode {
case .editor:
if leavingBoard { showPageInEditor() }
boardContainer.isHidden = true
splitView.isHidden = false
if previewScroll.superview != nil {
previewScroll.removeFromSuperview()
}
applyWrap(AppSettings.softWrap)
containerView.window?.makeFirstResponder(editorTV)
case .split:
if leavingBoard { showPageInEditor() }
boardContainer.isHidden = true
splitView.isHidden = false
if previewScroll.superview == nil {
// Editor stays at index 0; append preview. (Editor was never removed.)
splitView.addArrangedSubview(previewScroll)
}
applyWrap(AppSettings.softWrap)
refreshPreview()
DispatchQueue.main.async {
let w = self.splitView.bounds.width
if w > 0 { self.splitView.setPosition(w * 0.52, ofDividerAt: 0) }
}
containerView.window?.makeFirstResponder(editorTV)
case .board:
returnPageToBoard()
splitView.isHidden = true
boardContainer.isHidden = false
DispatchQueue.main.async { self.boardContainer.boardView.centerOnMain() }
containerView.window?.makeFirstResponder(boardContainer.boardView)
}
refreshStatus()
}
// MARK: - Editor page binding (the editor follows the selected board page)
/// Called once after the board loads: the document text view (in the editor)
/// belongs to the main page card.
func bindInitialEditorPage() {
boardContainer.boardView.assignMainTextView(textView)
activeEditorCard = boardContainer.boardView.mainCardView
}
/// Leaving the board: drop the active page's text view back into its card.
private func returnPageToBoard() {
boardContainer.boardView.unfocusCard(commit: true)
activeEditorCard?.mountTextView()
editorScrollView.rulersVisible = false
}
/// Entering editor/split from the board: host the selected page's text view
/// (or the main page) in the editor and re-point all editor chrome to it.
private func showPageInEditor() {
let bv = boardContainer.boardView
bv.unfocusCard(commit: true)
guard let card = bv.editorPageCandidate(), let tv = card.unmountTextView() else { return }
activeEditorCard = card
tv.isEditable = true // idle board cards become editable in the editor
tv.isSelectable = true
editorScrollView.documentView = tv
editorScrollView.rulersVisible = AppSettings.lineNumbers
retargetEditor(to: tv)
}
/// Move the editor's observers, ruler, wrap, theme and metrics onto `tv`.
private func retargetEditor(to tv: NSTextView) {
if editorTV !== tv {
let c = NotificationCenter.default
c.removeObserver(self, name: NSText.didChangeNotification, object: editorTV)
c.removeObserver(self, name: NSTextView.didChangeSelectionNotification, object: editorTV)
editorTV = tv
c.addObserver(self, selector: #selector(textChanged), name: NSText.didChangeNotification, object: tv)
c.addObserver(self, selector: #selector(selectionChanged), name: NSTextView.didChangeSelectionNotification, object: tv)
}
themeEditorTextView()
ruler.retarget(to: tv)
ruler.updateThickness()
applyWrap(AppSettings.softWrap)
recomputeMetrics()
refreshPreview()
}
@objc private func cycleMode(_ sender: NSSegmentedControl) {
setMode(Mode(rawValue: sender.selectedSegment) ?? .editor)
}
// MARK: - Appearance
@objc private func appearanceChanged() { applyAppearance() }
/// Apply the current theme/font to whichever text view the editor is showing.
private func themeEditorTextView() {
let theme = AppSettings.theme
let font = AppSettings.editorFont
let tv = editorTV
tv.font = font
tv.backgroundColor = theme.background
tv.drawsBackground = false // the card / editor draws the background
tv.textColor = theme.foreground
tv.insertionPointColor = theme.caret
tv.selectedTextAttributes = [.backgroundColor: theme.selection]
tv.typingAttributes = [.font: font, .foregroundColor: theme.foreground]
let full = NSRange(location: 0, length: (tv.string as NSString).length)
tv.textStorage?.addAttributes([.font: font, .foregroundColor: theme.foreground], range: full)
}
func applyAppearance() {
let theme = AppSettings.theme
let font = AppSettings.editorFont
containerView.window?.appearance = theme.appearance
themeEditorTextView()
// Keep on-board (idle) text cards themed too, so they read correctly
// whether shown on the board or lifted into the editor.
boardContainer.boardView.applyTextTheme(font: font, fg: theme.foreground, caret: theme.caret)
editorScrollView.backgroundColor = theme.background
editorScrollView.drawsBackground = true
ruler.apply(theme: theme)
// Editor-only affordances: skip while the text view is reparented into
// the board (rulersVisible / applyWrap both read editorScrollView state).
if mode != .board {
editorScrollView.rulersVisible = AppSettings.lineNumbers
ruler.updateThickness()
applyWrap(AppSettings.softWrap)
}
previewScroll.backgroundColor = theme.background
previewTextView.backgroundColor = theme.background
previewTextView.drawsBackground = true
refreshPreview()
boardContainer.apply(theme: theme)
statusBar.layer?.backgroundColor = theme.gutterBackground.cgColor
for label in [leftStatus, centerStatus, rightStatus] { label.textColor = theme.secondaryText }
}
private func applyWrap(_ wrap: Bool) {
let tv = editorTV
guard let container = tv.textContainer else { return }
if wrap {
container.widthTracksTextView = true
container.size = NSSize(width: editorScrollView.contentSize.width, height: CGFloat.greatestFiniteMagnitude)
tv.isHorizontallyResizable = false
tv.autoresizingMask = [.width]
editorScrollView.hasHorizontalScroller = false
} else {
container.widthTracksTextView = false
container.size = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
tv.isHorizontallyResizable = true
tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
tv.autoresizingMask = [.width, .height]
editorScrollView.hasHorizontalScroller = true
}
tv.needsDisplay = true
}
// MARK: - Preview + status
@objc func textChanged() {
if mode == .split { refreshPreview() }
ruler.updateThickness()
recomputeMetrics()
refreshStatus()
}
@objc func selectionChanged() { refreshStatus() }
// Word/char/line counts are cached so the board's pan/zoom status updates
// don't re-scan a large pasted document on every notification.
private var metrics: (words: Int, chars: Int, lines: Int) = (0, 0, 1)
private func recomputeMetrics() {
let s = editorTV.string
metrics.chars = (s as NSString).length
metrics.words = s.split { $0 == " " || $0 == "\n" || $0 == "\t" || $0 == "\r" }.count
metrics.lines = s.isEmpty ? 1 : s.reduce(into: 1) { c, ch in if ch == "\n" { c += 1 } }
}
/// Called after the document sets text programmatically (load / openResults).
func refreshTextMetrics() { recomputeMetrics(); refreshStatus() }
private func refreshPreview() {
guard mode == .split else { return }
let theme = AppSettings.theme
let attributed = MarkdownRenderer.render(editorTV.string, theme: theme, baseSize: AppSettings.fontSize + 1)
previewTextView.textStorage?.setAttributedString(attributed)
previewTextView.textColor = theme.foreground
}
private func refreshStatus() {
let words = metrics.words, chars = metrics.chars, lines = metrics.lines
let theme = AppSettings.theme
if mode == .board {
let bv = boardContainer.boardView
if let card = bv.selectedCardView {
let f = card.frame
leftStatus.stringValue = "\(cardKindLabel(card.kind)) · \(Int(f.width.rounded()))×\(Int(f.height.rounded())) pt"
} else {
leftStatus.stringValue = "\(boardContainer.currentTool.name) tool · "
+ "\(bv.mainPageSize.shortLabel) \(bv.mainOrientation == .landscape ? "Landscape" : "Portrait")"
}
centerStatus.stringValue = "Board · \(theme.name) · \(boardContainer.zoomPercent)%"
let n = bv.cards.count
rightStatus.stringValue = "\(n) card\(n == 1 ? "" : "s") · \(words) words · \(chars) chars"
return
}
// editor / split line:col needs the live string
let ns = editorTV.string as NSString
let sel = editorTV.selectedRange()
var line = 1, col = 1, i = 0
while i < sel.location && i < ns.length {
if ns.character(at: i) == 10 { line += 1; col = 1 } else { col += 1 }
i += 1
}
var left = "Ln \(line), Col \(col)"
if sel.length > 0 { left += " · \(sel.length) selected" }
leftStatus.stringValue = left
let modeName = mode == .split ? "Split" : "Editor"
// Make it obvious when the editor is showing a board note page, not the .txt.
let pageNote = (activeEditorCard?.kind == .text) ? "Board note · " : ""
centerStatus.stringValue = "\(pageNote)\(modeName) · \(theme.name) · \(AppSettings.fontName) \(Int(AppSettings.fontSize))pt"
let mins = max(1, Int((Double(words) / 200.0).rounded(.up)))
rightStatus.stringValue = "\(words) words · \(chars) chars · \(lines) lines · ~\(mins) min read"
}
private func cardKindLabel(_ k: BoardCardKind) -> String {
switch k {
case .mainText: return "Main page"
case .text: return "Text page"
case .image: return "Image"
case .box: return "Box"
}
}
func setStatus(_ message: String) { centerStatus.stringValue = message }
// MARK: - Toolbar
private enum ID {
static let mode = NSToolbarItem.Identifier("mode")
static let font = NSToolbarItem.Identifier("font")
static let fontSize = NSToolbarItem.Identifier("fontSize")
static let theme = NSToolbarItem.Identifier("theme")
static let markup = NSToolbarItem.Identifier("markup")
static let wrap = NSToolbarItem.Identifier("wrap")
static let sync = NSToolbarItem.Identifier("sync")
static let postgres = NSToolbarItem.Identifier("postgres")
}
func installToolbar(on window: NSWindow) {
let toolbar = NSToolbar(identifier: "TextPlusToolbar")
toolbar.delegate = self
toolbar.displayMode = .iconOnly
toolbar.allowsUserCustomization = true
toolbar.autosavesConfiguration = true
window.toolbar = toolbar
window.toolbarStyle = .unified
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[ID.mode, .flexibleSpace, ID.markup, .space, ID.font, ID.fontSize, ID.theme,
ID.wrap, .flexibleSpace, ID.sync, ID.postgres]
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
[ID.mode, ID.markup, ID.font, ID.fontSize, ID.theme, ID.wrap, ID.sync, ID.postgres,
.flexibleSpace, .space]
}
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier id: NSToolbarItem.Identifier,
willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
switch id {
case ID.mode: return modeItem()
case ID.markup: return markupItem()
case ID.font: return fontItem()
case ID.fontSize: return fontSizeItem()
case ID.theme: return themeItem()
case ID.wrap: return wrapItem()
case ID.sync: return buttonItem(id: id, symbol: "arrow.up.circle", label: "Sync",
tip: "Sync Now (⌥⌘S)", action: #selector(toolbarSync))
case ID.postgres: return buttonItem(id: id, symbol: "cylinder.split.1x2", label: "Postgres",
tip: "Send Page to Postgres (⌥⌘P)", action: #selector(toolbarPostgres))
default: return nil
}
}
private func modeItem() -> NSToolbarItem {
let seg = NSSegmentedControl(labels: ["Editor", "Split", "Board"],
trackingMode: .selectOne, target: self, action: #selector(cycleMode(_:)))
seg.selectedSegment = mode.rawValue
seg.segmentStyle = .texturedRounded
modeSegment = seg
let item = NSToolbarItem(itemIdentifier: ID.mode)
item.view = seg
item.label = "View"
item.visibilityPriority = .high
return item
}
private func markupItem() -> NSToolbarItem {
let symbols = ["bold", "italic", "chevron.left.forwardslash.chevron.right",
"number", "list.bullet", "link"]
let seg = NSSegmentedControl()
seg.segmentCount = symbols.count
seg.trackingMode = .momentary
seg.segmentStyle = .texturedRounded
for (i, sym) in symbols.enumerated() {
seg.setImage(NSImage(systemSymbolName: sym, accessibilityDescription: sym), forSegment: i)
seg.setWidth(30, forSegment: i)
}
seg.target = self
seg.action = #selector(markupTapped(_:))
let item = NSToolbarItem(itemIdentifier: ID.markup)
item.view = seg
item.label = "Markup"
return item
}
private func fontItem() -> NSToolbarItem {
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 130, height: 24), pullsDown: false)
popup.addItems(withTitles: AppSettings.fontChoices)
popup.selectItem(withTitle: AppSettings.fontName)
popup.target = self
popup.action = #selector(fontChanged(_:))
let item = NSToolbarItem(itemIdentifier: ID.font)
item.view = popup
item.label = "Font"
return item
}
private func fontSizeItem() -> NSToolbarItem {
let seg = NSSegmentedControl(labels: ["A", "A+"], trackingMode: .momentary,
target: self, action: #selector(fontSizeTapped(_:)))
seg.segmentStyle = .texturedRounded
let item = NSToolbarItem(itemIdentifier: ID.fontSize)
item.view = seg
item.label = "Size"
return item
}
private func themeItem() -> NSToolbarItem {
let popup = NSPopUpButton(frame: NSRect(x: 0, y: 0, width: 120, height: 24), pullsDown: false)
popup.addItems(withTitles: EditorTheme.all.map { $0.name })
if let idx = EditorTheme.all.firstIndex(where: { $0.id == AppSettings.theme.id }) {
popup.selectItem(at: idx)
}
popup.target = self
popup.action = #selector(themeChanged(_:))
let item = NSToolbarItem(itemIdentifier: ID.theme)
item.view = popup
item.label = "Theme"
return item
}
private func wrapItem() -> NSToolbarItem {
let button = NSButton(image: NSImage(systemSymbolName: "text.alignleft", accessibilityDescription: "Wrap")!,
target: self, action: #selector(toggleWrap))
button.setButtonType(.pushOnPushOff)
button.bezelStyle = .texturedRounded
button.state = AppSettings.softWrap ? .on : .off
let item = NSToolbarItem(itemIdentifier: ID.wrap)
item.view = button
item.label = "Wrap"
item.toolTip = "Toggle soft wrap"
return item
}
private func buttonItem(id: NSToolbarItem.Identifier, symbol: String, label: String,
tip: String, action: Selector) -> NSToolbarItem {
let button = NSButton(image: NSImage(systemSymbolName: symbol, accessibilityDescription: label)!,
target: self, action: action)
button.bezelStyle = .texturedRounded
let item = NSToolbarItem(itemIdentifier: id)
item.view = button
item.label = label
item.toolTip = tip
return item
}
// MARK: - Toolbar actions
@objc private func markupTapped(_ sender: NSSegmentedControl) {
switch sender.selectedSegment {
case 0: document?.markupBold(sender)
case 1: document?.markupItalic(sender)
case 2: document?.markupCode(sender)
case 3: document?.markupHeading(sender)
case 4: document?.markupList(sender)
case 5: document?.markupLink(sender)
default: break
}
}
@objc private func fontChanged(_ sender: NSPopUpButton) {
AppSettings.set(AppSettings.fontNameKey, sender.titleOfSelectedItem ?? "SF Mono")
}
@objc private func fontSizeTapped(_ sender: NSSegmentedControl) {
let delta: CGFloat = sender.selectedSegment == 1 ? 1 : -1
let size = max(8, min(48, AppSettings.fontSize + delta))
AppSettings.set(AppSettings.fontSizeKey, Double(size))
}
@objc private func themeChanged(_ sender: NSPopUpButton) {
let theme = EditorTheme.all[sender.indexOfSelectedItem]
AppSettings.set(AppSettings.themeKey, theme.id)
}
@objc private func toggleWrap(_ sender: NSButton) {
AppSettings.set(AppSettings.softWrapKey, sender.state == .on)
}
@objc private func toolbarSync() { document?.syncNow(nil) }
@objc private func toolbarPostgres() { document?.sendToPostgres(nil) }
}

10
Sources/main.swift Normal file
View File

@ -0,0 +1,10 @@
import AppKit
// Subprocess pipes (scp, psql) can break mid-write; don't let SIGPIPE kill the app.
signal(SIGPIPE, SIG_IGN)
let app = NSApplication.shared
let appDelegate = AppDelegate()
app.delegate = appDelegate
app.setActivationPolicy(.regular)
app.run()

20
build.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
# Build TextPlus.app — run: ./build.sh
set -euo pipefail
cd "$(dirname "$0")"
APP="TextPlus.app"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
swiftc -O -module-name TextPlus \
Sources/*.swift \
-o "$APP/Contents/MacOS/TextPlus"
cp Info.plist "$APP/Contents/Info.plist"
if [ -f Resources/AppIcon.icns ]; then
cp Resources/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"
fi
codesign --force --sign - "$APP"
echo "Built $APP"

23
demo.md Normal file
View File

@ -0,0 +1,23 @@
# TextPlus
A **plaintext** editor that grew *teeth*: themes, live Markdown preview, a
paint canvas, and a pipeline that parses HTML and ships it to Postgres.
## What's in the box
- Five editor themes with a line-number gutter
- Live Markdown preview (you're looking at the source)
- A vector **paint canvas** — pen, shapes, arrows, text
- `scp`-on-save to any Mac on your tailnet
- HTML selector parsing → `marketplace_snapshots`
> Toggle the view with the segmented control top-left, or ⌃⌘1 / ⌃⌘2 / ⌃⌘3.
### A code sample
```swift
let canvas = DrawCanvasView()
canvas.tool = .arrow
```
Visit [the repo](https://example.com) and press ⌘B to **bold** a selection.

75
docs/BOARD_DESIGN.md Normal file
View File

@ -0,0 +1,75 @@
# Freeform Board — implementation blueprint
Vetted by a 13-agent design panel (winner: reuse-robust, with freeform-ux box-cards
and the editorScrollView-nesting fix grafted in).
## Core decisions
- **Board = 4th WorkspaceController.Mode** (`editor`, `split`, `board`). Canvas mode is
ABSORBED into the board — the board's shapes layer is the drawing surface.
- **The magnifying `boardScroll` is the only scroll view.** Cards are bare views
(NSTextView / NSImageView / CALayer box). NEVER nest `editorScrollView` inside it.
- **Main card hosts the document's real `textView`** (reparented bare, no scroll view),
so dirty/undo/find/line-col all keep working. Its text = the `.txt` bytes (never
serialized to the sidecar — no second copy can diverge).
- **Secondary text cards** are board-only annotations; their text lives in the sidecar,
not the `.txt`.
- **Clean boxes** = `BoardCard(kind: .box)` CALayer cards (fill `accent@0.18`, stroke
`accent`, cornerRadius 8). Freehand pen/shapes stay on the reused `DrawCanvasView`.
## View hierarchy (Sources/BoardContainer.swift)
```
BoardContainerView : NSView (sibling of splitView; same 4 pins; isHidden=true)
├─ palette : NSStackView (left, fixed width) Select/Box/Pen/Line/Arrow/Oval/Eraser,
│ stroke well, width slider, New Text Card, Add Image…, Portrait/Landscape,
│ zoom-to-fit, undo/redo, export PNG, send-to-tailnet (pinned, NOT zoomed)
└─ boardScroll : NSScrollView allowsMagnification, min .15 / max 4
└─ boardView : BoardView (NSView, isFlipped, wantsLayer, world 8000×6000, dot grid)
├─ shapes : DrawCanvasView (reused, canvasBackground=.clear, bottom)
├─ cardViews : [CardView] by z (TextCardView / ImageCardView / BoxCardView)
└─ selectionOverlay : SelectionOverlayView (8 handles, top, forwards events)
```
## Data model (Sources/Board.swift), Codable
- `BoardCardKind { mainText, text, image, box }`
- `PageOrientation { portrait(612×792), landscape(792×612) }`
- `BoardCard { id, kind, frame(world), z, text?, orientation?, imageFile?, fillHex?, strokeHex?, cornerRadius?, title? }`
- `BoardDoc { version, cards, shapes: DrawCanvasView.CanvasDoc, magnification, scrollOrigin, boardBackgroundHex? }`
- Hand-written `init(from:)` that `try?`-decodes each field with a default → missing/old
sidecar never throws.
## Persistence: bundle dir `foo.txt.board/`
- `board.json` (atomic write via replaceItemAt, pretty+sortedKeys)
- `img-<uuid>.png` assets (referenced by filename; bytes never in JSON; GC orphans on save)
- `preview.png` (derived, for send-to-tailnet)
- Load after Document.swift:41; save after Document.swift:121, all `try?`-guarded so a
sidecar failure NEVER fails the plaintext save. Main card text is never written here.
- Migration: if `foo.txt.board/` absent but `foo.txt.canvas.json` exists, import its
CanvasDoc into `BoardDoc.shapes` (one-way, leaves the old file).
## Focus transition
- Enter: double-click text card OR Return on selection → record preFocus mag/visibleRect,
raise z, animate `boardScroll.animator().setMagnification(clamp 1.01.5, centeredAt:)`,
textView editable+selectable, makeFirstResponder(textView).
- Exit: Esc / click empty / select other / leave mode → breakUndoCoalescing(), copy
secondary card text back + updateChangeCount, editable=false, makeFirstResponder(boardView),
animate back.
- **Mandatory:** CardView.hitTest returns self while unfocused (a non-selectable NSTextView
still returns self for a plain click). Shapes layer hit-tests only when a draw tool active.
## Must-handle pitfalls
1. Reparent only the bare textView, never editorScrollView (the #1 sleeper bug).
2. rulersVisible=false in board; restore on exit; keep editorScrollView alive.
3. Gate applyWrap when mode==.board (it reads editorScrollView.contentSize).
4. Return editor to splitView via insertArrangedSubview(at:0), never add (pane order).
5. NSScrollView.allowsMagnification only — no manual CGAffineTransform. convert(from:nil) keeps working.
6. CardView hitTest override (above).
7. Centralize textView reparenting in attachTextViewToBoard()/detachTextViewToEditor(); one superview owns it; never recreate it. Test editor→board→editor→split round-trip FIRST.
8. shapes DrawCanvasView canvasBackground=.clear (it opaque-fills bounds otherwise).
9. Two undo managers (text=document, structural=boardView); every structural mutation also updateChangeCount(.changeDone).
10. Board PNG export at magnification 1.0 over the union of content, not the 8000×6000 world.
11. Re-resolve CALayer cgColors in applyAppearance() (they don't auto-update on appearance change).
12. saveBoardSidecar wrapped in try?, after super.save succeeds.
## MVP cuts (defer)
connectors, marquee multi-select, alignment guides (keep grid-snap), card snapshotting,
interleaved shape/card z, multi-page-into-one-.txt, JSON forward-merge, QuickLook plugin.