textplus/README.md
monster 09972ccde1 TextPlus: native macOS plaintext editor with sync, scraping, and a freeform board
A no-Xcode AppKit app (swiftc + build.sh):
- Plaintext editor: 5 themes, line numbers, soft wrap, font controls, find,
  live Markdown preview (split mode), selection-aware Markdown toolbar.
- Freeform Board mode: zoomable/pannable canvas of page cards (portrait/landscape,
  US Letter/A4/Legal/Square/Wide/Note), clean boxes, images (paste/drop/panel),
  freehand pen/shapes, screen-grab eyedropper, nested right-click menu, rich
  status bar. The Editor follows the selected page; layout persists to a
  foo.txt.board sidecar; the .txt stays pure plaintext.
- scp-on-save to a Tailscale Mac (key auth, in-flight/quit drain).
- HTML scraping pipeline: custom selector engine + Discogs profile, ingests to
  Postgres (psql) into a marketplace_snapshots schema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:08:59 +10:00

177 lines
8.5 KiB
Markdown

# 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`.