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:
commit
09972ccde1
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal 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
47
Info.plist
Normal 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
176
README.md
Normal 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
BIN
Resources/AppIcon.icns
Normal file
Binary file not shown.
262
Sources/AppDelegate.swift
Normal file
262
Sources/AppDelegate.swift
Normal 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
147
Sources/Appearance.swift
Normal 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
184
Sources/Board.swift
Normal 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
1348
Sources/BoardContainer.swift
Normal file
File diff suppressed because it is too large
Load Diff
556
Sources/Document.swift
Normal file
556
Sources/Document.swift
Normal 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
395
Sources/DrawingCanvas.swift
Normal 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
739
Sources/HTMLScanner.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
148
Sources/LineNumberRuler.swift
Normal file
148
Sources/LineNumberRuler.swift
Normal 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
292
Sources/Markdown.swift
Normal 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
428
Sources/PostgresStore.swift
Normal 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)]) }
|
||||
}
|
||||
}
|
||||
70
Sources/ScrapeProfile.swift
Normal file
70
Sources/ScrapeProfile.swift
Normal 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
170
Sources/SettingsView.swift
Normal 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 1–65535",
|
||||
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
209
Sources/SyncEngine.swift
Normal 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
39
Sources/TextMarkup.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
564
Sources/WorkspaceController.swift
Normal file
564
Sources/WorkspaceController.swift
Normal 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
10
Sources/main.swift
Normal 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
20
build.sh
Executable 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
23
demo.md
Normal 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
75
docs/BOARD_DESIGN.md
Normal 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.0–1.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.
|
||||
Loading…
Reference in New Issue
Block a user