Found by reviewing the plugin rather than running it. Nothing was live — the table does not exist and the plugin is not installed — so all of these were waiting for the first person to use it. ── The second unnamed design could not be saved ── `uniqueIndex(userId, title)`, and every design starts life called "Untitled": `titleOf` returns it for a document with no title and the API defaults to it. So the first saved, the second collided, and the insert threw under a panel that could only say "Changes are not being saved." Renaming one design to match another did the same. Two designs may share a name, as files and notes and dashboards do. Nothing looks a design up by title, so uniqueness bought nothing. The index is now ordinary and on the columns the list query actually uses. The lesson from `dashboards.id` that the schema comment cites is that a key must be scoped to its owner. `userId` on the row does that; making the pair unique as well imported the collision it was citing, one account at a time. ── New did not start a new design ── It cleared `?doc=`, and no parameter already means "open what I had last", answered with `/documents/latest`. So New handed back the design on screen and pointed the next save at its id. There is a `new` sentinel now, in its own module because both panels read it and neither owns it. ── Two ways the load effect raced the debounced save ── Changing `?doc=` does not unmount the panel, so the unmount flush never ran for it: an edit made within the debounce and navigated away from was written after `latest.current` had been replaced, which is to say never. And the first save of a new design puts its id in the URL, which lands in the same effect — re-reading the row overwrote in-memory state with the copy the server had just been given, discarding whatever was drawn during the round trip. The effect now flushes what is pending before it reads, and does not re-read a document it already holds. The editor is keyed on a load generation rather than on `row?.id`, which could not express "blank again" and left the previous design on screen with `rowId` cleared underneath it. ── The typecheck ── 354 of the 355 errors were upstream's tree measured against this platform's settings, which it was never written for. `bunx tsgo` is how this repo is kept clean and it could not be clean while this plugin existed. `exclude` does not help: adapter.ts imports the tree back in. So vendor.sh marks each vendored file unchecked in the same deterministic pass that rewrites its imports. Types still resolve; only the errors inside those files are silenced, which is what the adapter already says the seam is for. Whole repo: 0. ── PROVENANCE.md understated what is done to the copy ── It said "the one edit vendor.sh makes" and described the import rewrite. It also runs apply-host.py, which places our own lib/host.ts and wires three export paths. Four files are not upstream and the document whose job is making the copy trustworthy did not say so. It lists all four now. Also: web/host.ts imported a path only correct at its destination, so the file could never typecheck where it is edited — apply-host.py rewrites that line when placing it, and re-marks the placed copy, which it overwrites after vendor.sh has marked it. PUT is no longer full-replace: omitting `title` renamed a design to "Untitled" and omitting `ui` nulled it, a trap set for the second caller. And a document is bounded at 4 MB, measured in bytes, where the title was capped at 200 and the document at nothing.
256 lines
10 KiB
Python
Executable File
256 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Wire the host adapter into the vendored editor.
|
|
|
|
Run by `vendor.sh` immediately after the import rewrite, so `web/vendor/` becomes a faithful copy of
|
|
upstream plus exactly this and nothing else.
|
|
|
|
── Why a script and not a .patch ──
|
|
|
|
Upstream moves fast and reformats. Between `cfde698` and `5c83ac6`, one day apart, the i18n import
|
|
line changed AND the whole component was re-indented from 6 spaces to 8 AND an `editAccess` guard
|
|
appeared. A context diff would have failed on all three, none of which have anything to do with us.
|
|
|
|
So every anchor here matches on STRUCTURE with flexible whitespace, and re-indents its replacement to
|
|
whatever it found. When an anchor genuinely disappears the script stops with the file and the thing
|
|
it wanted — loudly, because a silent mis-wire is the failure that matters.
|
|
|
|
What it wires is what upstream said a complete version needs (lnkiai/m3e-canvas#56): the host hooks,
|
|
in ALL THREE export paths rather than one.
|
|
|
|
── Three, and not the other two ──
|
|
|
|
A scan for `clipboard.writeText` finds FIVE call sites. Two of them are in `ShareMenu.tsx`, which
|
|
upstream added after the review, and they are deliberately NOT wired:
|
|
|
|
copyAsk an "ask an AI" blurb containing a guide URL and the user's one-line idea
|
|
copyLink a share link to the design
|
|
|
|
Neither is the generated prompt, so neither belongs behind `onExport`. This is written down because
|
|
the count looks wrong at a glance, and the previous round of this work was rejected for wiring one
|
|
path out of three — the next person to run that scan should not have to re-derive which is which.
|
|
"""
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
VENDOR = Path(__file__).resolve().parent.parent / "web" / "vendor"
|
|
|
|
|
|
def fail(what: str, where: str) -> None:
|
|
raise SystemExit(
|
|
f"\n Could not find {what} in {where}.\n"
|
|
f" Upstream has moved underneath this script. Read the file, re-anchor, and re-run.\n"
|
|
f" This failing is the point — it is how a silent mis-wire is avoided.\n"
|
|
)
|
|
|
|
|
|
def reindent(block: str, pad: str) -> str:
|
|
"""Re-indent a replacement to sit at the indentation actually found in the file."""
|
|
lines = block.split("\n")
|
|
return "\n".join(pad + l if l.strip() else l for l in lines)
|
|
|
|
|
|
def sub_once(src: str, pattern: str, build, what: str, where: str, flags=0) -> str:
|
|
match = re.search(pattern, src, flags)
|
|
if not match:
|
|
fail(what, where)
|
|
pad = re.match(r"[ \t]*", match.group(0)).group(0)
|
|
return src[: match.start()] + build(match, pad) + src[match.end() :]
|
|
|
|
|
|
def add_import(src: str, spec: str, where: str) -> str:
|
|
lines = src.split("\n")
|
|
idx = [i for i, l in enumerate(lines) if l.startswith("import ")]
|
|
if not idx:
|
|
fail("any import statement", where)
|
|
lines.insert(idx[-1] + 1, spec)
|
|
return "\n".join(lines)
|
|
|
|
|
|
# ── Canvas.tsx ────────────────────────────────────────────────────────────────────────────────────
|
|
|
|
def canvas(src: str, name: str) -> str:
|
|
src = add_import(src, 'import { useHost } from "./lib/host";', name)
|
|
|
|
src = sub_once(
|
|
src,
|
|
r"[ \t]*export default function Page\(\) \{",
|
|
lambda m, pad: m.group(0)
|
|
+ f"\n{pad} // Optional host hooks; every default is the standalone behaviour. See lib/host.ts."
|
|
+ f"\n{pad} const host = useHost();",
|
|
"the component entry point",
|
|
name,
|
|
)
|
|
|
|
src = sub_once(
|
|
src,
|
|
r"([ \t]*)const d = localStorage\.getItem\(DOC_KEY\);\n[ \t]*if \(d\) \{",
|
|
lambda m, pad: reindent(
|
|
'// Branch on whether the host SUPPLIED a loader, not on what it returned: a host\n'
|
|
'// returning null means "no saved document", which must start fresh rather than\n'
|
|
"// fall back to this browser's standalone copy. Raised on lnkiai/m3e-canvas#56.\n"
|
|
"const hosted = host.loadDoc ? host.loadDoc() : null;\n"
|
|
"const d = host.loadDoc ? null : localStorage.getItem(DOC_KEY);\n"
|
|
"if (hosted) {\n"
|
|
" hadDocRef.current = true;\n"
|
|
" applyDoc(hosted, false);\n"
|
|
"} else if (d) {",
|
|
pad,
|
|
).lstrip(),
|
|
"the document read",
|
|
name,
|
|
)
|
|
|
|
src = sub_once(
|
|
src,
|
|
r"([ \t]*)const u = localStorage\.getItem\(UI_KEY\);\n[ \t]*if \(u\) \{\n[ \t]*const ui = JSON\.parse\(u\);",
|
|
lambda m, pad: reindent(
|
|
"const stored = host.loadUi ? null : localStorage.getItem(UI_KEY);\n"
|
|
"const ui = host.loadUi ? host.loadUi() : stored ? JSON.parse(stored) : null;\n"
|
|
"if (ui) {",
|
|
pad,
|
|
).lstrip(),
|
|
"the UI read",
|
|
name,
|
|
)
|
|
|
|
src = sub_once(
|
|
src,
|
|
r"([ \t]*)localStorage\.setItem\(\s*DOC_KEY,\s*JSON\.stringify\((\{[^{}]*\})\),?\s*\);",
|
|
lambda m, pad: reindent(
|
|
f"const payload = {m.group(2)};\n"
|
|
"if (host.saveDoc) host.saveDoc(payload as Partial<Doc>);\n"
|
|
"else localStorage.setItem(DOC_KEY, JSON.stringify(payload));",
|
|
pad,
|
|
).lstrip(),
|
|
"the document write",
|
|
name,
|
|
re.S,
|
|
)
|
|
|
|
src = sub_once(
|
|
src,
|
|
r"([ \t]*)localStorage\.setItem\(\s*UI_KEY,\s*JSON\.stringify\((\{[^{}]*\})\),?\s*\);",
|
|
lambda m, pad: reindent(
|
|
"const ui = {%s};\n" % " ".join(m.group(2)[1:-1].split())
|
|
+ "if (host.saveUi) host.saveUi(ui);\n"
|
|
"else localStorage.setItem(UI_KEY, JSON.stringify(ui));",
|
|
pad,
|
|
).lstrip(),
|
|
"the UI write",
|
|
name,
|
|
re.S,
|
|
)
|
|
|
|
return sub_once(
|
|
src,
|
|
r"([ \t]*)await navigator\.clipboard\.writeText\(effectivePrompt\(doc, widths, lang\)\);",
|
|
lambda m, pad: reindent(
|
|
"const text = effectivePrompt(doc, widths, lang);\n"
|
|
"// The hook's own failure is isolated: a broken host must not also break copying.\n"
|
|
"let handled = false;\n"
|
|
"try { handled = host.onExport?.(text, doc) === true; } catch {}\n"
|
|
"if (handled) return;\n"
|
|
"await navigator.clipboard.writeText(text);",
|
|
pad,
|
|
).lstrip(),
|
|
"the toolbar export (1 of 3)",
|
|
name,
|
|
)
|
|
|
|
|
|
# ── the other two export paths ────────────────────────────────────────────────────────────────────
|
|
|
|
def prompt_panel(src: str, name: str) -> str:
|
|
src = add_import(src, 'import { useHost } from "../lib/host";', name)
|
|
src = sub_once(
|
|
src,
|
|
r"[ \t]*const \[copied, setCopied\] = useState\(false\);",
|
|
lambda m, pad: f"{pad}const host = useHost();\n" + m.group(0),
|
|
"the copied state",
|
|
name,
|
|
)
|
|
return sub_once(
|
|
src,
|
|
r"([ \t]*)await navigator\.clipboard\.writeText\(text\);",
|
|
lambda m, pad: reindent(
|
|
"let handled = false;\n"
|
|
"try { handled = host.onExport?.(text, null) === true; } catch {}\n"
|
|
"if (handled) { setCopied(true); return; }\n"
|
|
"await navigator.clipboard.writeText(text);",
|
|
pad,
|
|
).lstrip(),
|
|
"the copy call (2 of 3)",
|
|
name,
|
|
)
|
|
|
|
|
|
def inspector(src: str, name: str) -> str:
|
|
src = add_import(src, 'import { useHost } from "../lib/host";', name)
|
|
# The hook belongs to whichever component owns `prompt`; it is taken beside the copied state.
|
|
src = sub_once(
|
|
src,
|
|
r"[ \t]*const \[copied, setCopied\] = useState\(false\);",
|
|
lambda m, pad: f"{pad}const host = useHost();\n" + m.group(0),
|
|
"the copied state",
|
|
name,
|
|
)
|
|
return sub_once(
|
|
src,
|
|
r"([ \t]*)await navigator\.clipboard\.writeText\(prompt\);",
|
|
lambda m, pad: reindent(
|
|
"let handled = false;\n"
|
|
"try { handled = host.onExport?.(prompt, null) === true; } catch {}\n"
|
|
"if (handled) { setCopied(true); return; }\n"
|
|
"await navigator.clipboard.writeText(prompt);",
|
|
pad,
|
|
).lstrip(),
|
|
"the export copy call (3 of 3)",
|
|
name,
|
|
)
|
|
|
|
|
|
def main() -> int:
|
|
if not VENDOR.exists():
|
|
raise SystemExit("web/vendor/ is missing — run scripts/vendor.sh first.")
|
|
|
|
# `lib/host.ts` is OURS, not upstream's — upstream declined it. The canonical copy is
|
|
# `web/host.ts`, edited like any other file we own; this places it where the vendored code's
|
|
# imports expect it. Without this step `vendor.sh` leaves those imports dangling, because it
|
|
# wipes the directory before copying.
|
|
source = VENDOR.parent / "host.ts"
|
|
if not source.exists():
|
|
raise SystemExit("web/host.ts is missing — it is the source for the vendored lib/host.ts.")
|
|
# One line is rewritten on the way in. `web/host.ts` is edited where it lives, so it imports
|
|
# `./vendor/lib/tokens` — which is correct there and wrong once placed a directory deeper.
|
|
# Deterministic, like vendor.sh own import rewrite, and it keeps the canonical copy typecheckable
|
|
# instead of permanently carrying an unresolvable import for the sake of its destination.
|
|
placed = source.read_text(encoding="utf-8").replace('"./vendor/lib/tokens"', '"./tokens"')
|
|
if '"./tokens"' not in placed:
|
|
fail("the tokens import to rewrite", "web/host.ts")
|
|
|
|
# Marked like the rest of the vendored tree, because this step runs AFTER vendor.sh has marked it
|
|
# and writes the file whole — so without this line the mark is lost on every re-vendor and this one
|
|
# file starts failing the typecheck again. The canonical copy at web/host.ts stays checked; only
|
|
# the placed copy is exempt, for the same reason its neighbours are.
|
|
if not placed.startswith("// @ts-nocheck"):
|
|
placed = "// @ts-nocheck — vendored, see PROVENANCE.md\n" + placed
|
|
|
|
(VENDOR / "lib" / "host.ts").write_text(placed, encoding="utf-8")
|
|
print(" placed lib/host.ts (ours)")
|
|
for rel, fn in (
|
|
("Canvas.tsx", canvas),
|
|
("components/PromptPanel.tsx", prompt_panel),
|
|
("components/Inspector.tsx", inspector),
|
|
):
|
|
path = VENDOR / rel
|
|
path.write_text(fn(path.read_text(encoding="utf-8"), rel), encoding="utf-8")
|
|
print(f" wired {rel}")
|
|
print(" all three export paths wired")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|