chore: add gaps plan 31457a4c
Steve Simkins · 2026-05-16 19:24 1 file(s) · +179 −0
gaps-plan.md (added) +179 −0
1 +
# Plan: bring Go ports to parity with Rust originals
2 +
3 +
## Context
4 +
5 +
Repo `/home/stevedylandev/Developer/andromeda` has paired Rust + Go versions of 10 apps. Audit (Phase 1) found gaps in 4 Go apps. User wants full parity. Deep-dive (Phase 2) corrected the initial gap list — some "missing" items in jotts-go are already implemented, and the posts EXIF strip referenced earlier does **not** exist in the Rust source either.
6 +
7 +
This plan covers only the real, confirmed gaps.
8 +
9 +
---
10 +
11 +
## Apps at parity (no work)
12 +
13 +
bookmarks, cellar, easel, feeds, library, og.
14 +
15 +
---
16 +
17 +
## Gap 1 — `posts-go`: R2/S3 storage backend
18 +
19 +
**Rust source to mirror:**
20 +
- `apps/posts/src/storage.rs` — `R2Config { bucket, creds, public_url, http }`, methods `from_env`, `put_object`, `delete_object`, `public_url_for`. Uses `rusty_s3 = "0.9"` + reqwest.
21 +
- `apps/posts/src/server/mod.rs:552` — init at startup, store `Option<R2Config>` in `AppState`.
22 +
- `apps/posts/src/server/handlers/admin.rs:439-522` — `admin_upload_file` routes via `if let Some(r2) = &state.r2`, sets `storage_backend` to `"r2"` or `"local"`, rolls back on failure.
23 +
- `apps/posts/src/server/handlers/public.rs:165-199` — `serve_uploaded_file` redirects to `r2.public_url_for(filename)` when backend is r2.
24 +
25 +
**EXIF strip is NOT in Rust** — drop from scope.
26 +
27 +
**Go changes (apps/posts-go/):**
28 +
- New package `storage/` with interface:
29 +
  ```go
30 +
  type Backend interface {
31 +
      Put(ctx context.Context, key, contentType string, data []byte) error
32 +
      Delete(ctx context.Context, key string) error
33 +
      PublicURL(key string) string
34 +
      Name() string // "local" | "r2"
35 +
  }
36 +
  ```
37 +
- `storage/local.go` — wrap existing FS funcs from `storage.go`.
38 +
- `storage/r2.go` — use `github.com/aws/aws-sdk-go-v2/service/s3` with custom endpoint resolver for R2 (`https://<account>.r2.cloudflarestorage.com`). Or `github.com/minio/minio-go/v7` for less ceremony.
39 +
- `app.go` — add `Storage storage.Backend` field.
40 +
- `main.go` — `if os.Getenv("R2_BUCKET") != "" { storage.NewR2(...) } else { storage.NewLocal(uploadsDir) }`.
41 +
- `handlers_admin.go` (~line 301 `adminUploadFile`) — call `a.Storage.Put(...)`, capture `a.Storage.Name()` for DB insert, delete on rollback.
42 +
- `handlers_public.go` (~line 156 `serveUploadedFile`) — if `f.StorageBackend == "r2"`, `http.Redirect(... StatusTemporaryRedirect)`.
43 +
- `db.go` (~line 395 `createFile`) — pass backend string; column already exists.
44 +
- `.env.example` — add `R2_ACCOUNT_ID`, `R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `R2_BUCKET`, `R2_PUBLIC_URL`.
45 +
46 +
---
47 +
48 +
## Gap 2 — `shrink-go`: EXIF preserve + GPS strip
49 +
50 +
**Rust source to mirror:** `apps/shrink/src/server.rs:103-207` (`strip_gps_from_exif`). Crates: `img-parts = "0.3"` + `image = "0.25"`. Algorithm:
51 +
1. `Jpeg::from_bytes(orig)` → `j.exif().to_vec()` (raw APP1 payload).
52 +
2. Re-encode resized JPEG (loses EXIF).
53 +
3. Parse raw EXIF: TIFF header (II/MM), read IFD0 offset, walk entries (12 bytes each) for tag `0x8825` (GPS IFD pointer), zero the GPS IFD's entry count (2 bytes at the pointed offset).
54 +
4. `out_jpeg.set_exif(Some(exif.into()))`, write final bytes.
55 +
56 +
**Go changes (apps/shrink-go/):**
57 +
- Add deps: `github.com/dsoprea/go-exif/v3` and `github.com/dsoprea/go-jpeg-image-structure/v2`.
58 +
- New `exif.go`:
59 +
  - `extractExif(orig []byte) []byte` — use go-jpeg-image-structure segment list, return APP1 bytes (skip first 6 `Exif\0\0` prefix).
60 +
  - `stripGPS(exif []byte) []byte` — mirror Rust byte-level walk (don't use go-exif builder; cheaper to keep parity).
61 +
  - `injectExif(jpeg, exif []byte) []byte` — splice modified APP1 after SOI marker.
62 +
- `image.go` / `handlers.go` — wrap compress flow: extract before resize, strip GPS, inject after re-encode.
63 +
64 +
---
65 +
66 +
## Gap 3 — `sipp-go`: content wrap toggle (TUI theme is out of scope)
67 +
68 +
**Already done in Go** (verified via `apps/sipp-go/tui/`): syntect→chroma highlight, clipboard auto-copy on select (`update.go:244-261`), line numbers (`ShowLineNumbers = true`), vim keybindings (`keys.go`).
69 +
70 +
**Remaining:** Ctrl+W content wrap toggle (Rust `wrap_content: bool`).
71 +
72 +
**Go changes (apps/sipp-go/tui/):**
73 +
- `model.go` — add `wrapContent bool` field.
74 +
- `keys.go` — add `WrapToggle` binding (`ctrl+w`).
75 +
- `update.go` — when focus is content view/edit and `WrapToggle` matches, flip flag, reset scroll, emit status message.
76 +
- `view.go` — when rendering content viewport/textarea, branch on `wrapContent`.
77 +
78 +
**Custom .tmTheme loading:** Chroma has no native TextMate XML loader. Defer — README already documents the trade-off. Re-open as a follow-up if user wants it; will require either a tmTheme→chroma converter or hand-porting the two themes to chroma `chroma.MustNewStyle`.
79 +
80 +
---
81 +
82 +
## Gap 4 — `jotts-go`: complete the TUI editor
83 +
84 +
**Already done in Go** (verified):
85 +
- `cmd_auth.go` — interactive auth + `~/.config/jotts/config.toml` (BurntSushi/toml).
86 +
- `cmd_upload.go` — file → note + clipboard.
87 +
- `cmd_server.go:29` — startup `sessions.PruneExpired()` (in `crates-go/auth/auth.go`).
88 +
89 +
**Remaining:** interactive TUI editor. Mirror `apps/jotts/src/tui/{app,events,render}.rs` + `apps/jotts/src/tui.rs`.
90 +
91 +
**Go changes (apps/jotts-go/tui/):**
92 +
- `model.go` — `Focus` enum: List, Content, CreateTitle, CreateContent, EditTitle, EditContent, Search.
93 +
- `keys.go` — vim bindings: `hjkl`, `y` (copy content), `Y` (copy share link), `d` (delete with confirm), `c` (new), `e` (edit), `E` (external editor), `o` (open in browser), `/` (search), `?` (help), `q`/`esc` (quit/back).
94 +
- `update.go` — mode FSM driving Focus transitions; copy triggers via `atotto/clipboard.WriteAll`; status-line messages.
95 +
- `editor.go` (new) — external editor: read `$EDITOR`, write content to `os.CreateTemp`, `exec.Command(editor, path).Run()` with stdio attached to current TTY, re-read file on exit.
96 +
- `browser.go` (new) — open `<remote_url>/notes/<short_id>` via `github.com/pkg/browser` (cross-platform).
97 +
- `view.go` — two-pane layout (lipgloss `JoinHorizontal`, 30/70), borders/title styles, help line at bottom.
98 +
- Markdown render: keep existing `glamour` (acceptable substitute for syntect — agent confirmed parity in behavior).
99 +
- `backend.go` — already abstracts local/remote; reuse.
100 +
101 +
**Deps to add:** `github.com/pkg/browser` (everything else already in go.mod).
102 +
103 +
---
104 +
105 +
## Critical files (modified or created)
106 +
107 +
| App | Path | Action |
108 +
|-----|------|--------|
109 +
| posts-go | `storage/{interface,local,r2}.go` | new |
110 +
| posts-go | `app.go`, `main.go`, `handlers_admin.go`, `handlers_public.go`, `db.go`, `.env.example` | edit |
111 +
| shrink-go | `exif.go` | new |
112 +
| shrink-go | `image.go`, `handlers.go`, `go.mod` | edit |
113 +
| sipp-go | `tui/{model,keys,update,view}.go` | edit |
114 +
| jotts-go | `tui/{editor,browser}.go` | new |
115 +
| jotts-go | `tui/{model,keys,update,view}.go`, `go.mod` | edit |
116 +
117 +
---
118 +
119 +
## Reused existing utilities
120 +
121 +
- `crates-go/auth/auth.go` — sessions, bearer/session middleware (no changes).
122 +
- `crates-go/web` — embedded handler, render helpers (no changes).
123 +
- `crates-go/sqlite`, `crates-go/config`, `crates-go/darkmatter` — reused as-is.
124 +
- posts-go local FS funcs already in `storage.go` — wrap into `LocalStorage`.
125 +
- sipp-go clipboard + chroma flow already wired — only add wrap toggle.
126 +
- jotts-go `tui/backend.go` (local + remote impls) — reused.
127 +
128 +
---
129 +
130 +
## Execution order (suggested)
131 +
132 +
1. **shrink-go EXIF** — smallest, self-contained, no schema changes.
133 +
2. **sipp-go wrap toggle** — ~20 LoC, fast win.
134 +
3. **posts-go R2** — biggest data-path change; needs R2 creds for end-to-end test.
135 +
4. **jotts-go TUI** — largest, mostly UI iteration; do last so other parity work isn't blocked.
136 +
137 +
---
138 +
139 +
## Verification
140 +
141 +
Per app:
142 +
143 +
**shrink-go**
144 +
```bash
145 +
cd apps/shrink-go && go build ./... && go run .
146 +
# upload a JPEG with GPS via the form, download result, run:
147 +
exiftool result.jpg | rg -i 'gps|orientation|make|model'
148 +
# expect: GPS fields absent, other EXIF present
149 +
```
150 +
151 +
**sipp-go**
152 +
```bash
153 +
cd apps/sipp-go && go run ./cmd/server
154 +
# open snippet, press Ctrl+W, confirm wrap behavior toggles, scroll resets
155 +
```
156 +
157 +
**posts-go**
158 +
```bash
159 +
cd apps/posts-go && go build ./... && go test ./...
160 +
# unset R2 vars: upload via admin → file lands in uploads/, GET /uploads/<f> returns 200
161 +
# set R2 vars (test bucket): upload → check bucket contains object, GET returns 307 to R2 public URL
162 +
sqlite3 posts.sqlite "select storage_backend from files order by id desc limit 5;"
163 +
```
164 +
165 +
**jotts-go**
166 +
```bash
167 +
cd apps/jotts-go && go run . server &
168 +
go run . tui --remote http://localhost:3000 --api-key $KEY
169 +
# walk: create note, edit, y copies content, Y copies link, E opens $EDITOR, o opens browser, d deletes
170 +
```
171 +
172 +
Cross-cutting: `go vet ./...` and `go build ./...` from repo root after each app's changes.
173 +
174 +
---
175 +
176 +
## Out of scope (recorded for follow-up)
177 +
178 +
- sipp-go custom `.tmTheme` loading — needs chroma converter or hand-port; defer per README's existing call-out.
179 +
- posts-go EXIF strip on upload — not in Rust either; only do if user asks separately.