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>
177 lines
8.5 KiB
Markdown
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`.
|