feat(server): views.file → form editor on browser navigation
A no-slash GET to a data file, in a directory whose cascade declares
views.file = {tool: form}, now serves the form editor bound to that file
(render-edit; POST goes to the canonical <file>.yaml.html update URL).
Gated on Accept: text/html so it only fires for browser NAVIGATIONS — the
tables client reads rows via fetch() (Accept: */*) and gets raw YAML
unchanged, and ?raw is an explicit bytes escape hatch. A directory without
views.file keeps serving raw bytes. Opt-in per subtree; presentation only
(ACL/WORM stay orthogonal and server-enforced).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3e7aa34e49
commit
ee371c5bb2
2 changed files with 96 additions and 0 deletions
|
|
@ -1236,6 +1236,27 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
|||
return
|
||||
}
|
||||
|
||||
// views.file → form editor. A browser NAVIGATION (Accept: text/html) to a
|
||||
// no-slash data file whose cascade declares views.file = {tool: form}
|
||||
// serves the form editor bound to that file. Programmatic reads — the
|
||||
// tables client fetches rows with Accept: */* — and an explicit ?raw fall
|
||||
// through to the raw bytes (the injected-row / ServeFile path below), so
|
||||
// this never breaks row fetching. The POST goes to the canonical
|
||||
// <file>.yaml.html update URL (the existing form-update handler).
|
||||
if r.Method == http.MethodGet && !r.URL.Query().Has("raw") &&
|
||||
strings.Contains(r.Header.Get("Accept"), "text/html") {
|
||||
if v, ok := zddc.ViewAt(cfg.Root, filepath.Dir(absPath), "file"); ok && v.Tool == "form" {
|
||||
fr := &handler.FormRequest{
|
||||
Kind: "render-edit",
|
||||
SpecPath: filepath.Join(filepath.Dir(absPath), "form.yaml"),
|
||||
DataPath: absPath,
|
||||
SubmitURL: urlPath + ".html",
|
||||
}
|
||||
handler.ServeForm(cfg, fr, w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// (MD→{docx,html,pdf} on-demand conversion now lives at
|
||||
// `GET /<dir>/<file>.{docx,html,pdf}` (virtual file URL,
|
||||
// see RecognizeVirtualConvert). The .md source serves
|
||||
|
|
|
|||
|
|
@ -1057,3 +1057,78 @@ func writeRootBundle(t *testing.T, root string, members map[string]string) {
|
|||
t.Fatalf("write bundle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDispatchFileToFormView locks in the views.file → form shape: a browser
|
||||
// NAVIGATION (Accept: text/html) to a no-slash data file, in a dir whose
|
||||
// cascade declares views.file = {tool: form}, serves the form editor bound to
|
||||
// that file — while a programmatic fetch (Accept: */*, the tables client) and
|
||||
// an explicit ?raw still get raw bytes, so row fetching never breaks. A dir
|
||||
// without views.file keeps serving raw bytes on navigation too.
|
||||
func TestDispatchFileToFormView(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, ".zddc"),
|
||||
"acl:\n permissions:\n \"*\": rwcd\n")
|
||||
dir := filepath.Join(root, "Proj", "records")
|
||||
mustMkdir(t, dir)
|
||||
// views.file declared on the records dir → form editor for its files.
|
||||
mustWrite(t, filepath.Join(dir, ".zddc"),
|
||||
"views:\n file:\n tool: form\n")
|
||||
// Form schema lives in the supporting-files reserve.
|
||||
mustMkdir(t, filepath.Join(dir, ".zddc.d"))
|
||||
mustWrite(t, filepath.Join(dir, ".zddc.d", "form.yaml"),
|
||||
"schema:\n type: object\n properties:\n title:\n type: string\n")
|
||||
mustWrite(t, filepath.Join(dir, "rec1.yaml"), "title: Hello\n")
|
||||
// A sibling file in a dir WITHOUT views.file stays raw.
|
||||
mustWrite(t, filepath.Join(root, "Proj", "plain.yaml"), "x: 1\n")
|
||||
|
||||
idx, err := archive.BuildIndex(root)
|
||||
if err != nil {
|
||||
t.Fatalf("BuildIndex: %v", err)
|
||||
}
|
||||
cfg := config.Config{Root: root, IndexPath: ".archive", EmailHeader: "X-Auth-Request-Email"}
|
||||
ring := handler.NewLogRing(10)
|
||||
appsSrv, err := setupApps(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("setupApps: %v", err)
|
||||
}
|
||||
|
||||
do := func(path string, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
dispatch(cfg, idx, ring, appsSrv, nil, rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
// Navigation → form editor HTML.
|
||||
rec := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "text/html"})
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("navigation: status=%d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
|
||||
t.Errorf("navigation Content-Type=%q, want text/html (form)", ct)
|
||||
}
|
||||
if strings.Contains(rec.Body.String(), "title: Hello") {
|
||||
t.Errorf("navigation served raw YAML, want the form editor")
|
||||
}
|
||||
|
||||
// Programmatic fetch (Accept: */*) → raw YAML bytes.
|
||||
rec2 := do("/Proj/records/rec1.yaml", map[string]string{"Accept": "*/*"})
|
||||
if rec2.Code != http.StatusOK || !strings.Contains(rec2.Body.String(), "title: Hello") {
|
||||
t.Errorf("fetch: status=%d body=%q, want raw YAML", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
|
||||
// ?raw escape hatch → raw bytes even for a browser.
|
||||
rec3 := do("/Proj/records/rec1.yaml?raw=1", map[string]string{"Accept": "text/html"})
|
||||
if !strings.Contains(rec3.Body.String(), "title: Hello") {
|
||||
t.Errorf("?raw body=%q, want raw YAML", rec3.Body.String())
|
||||
}
|
||||
|
||||
// No views.file declared → navigation still serves raw bytes.
|
||||
rec4 := do("/Proj/plain.yaml", map[string]string{"Accept": "text/html"})
|
||||
if !strings.Contains(rec4.Body.String(), "x: 1") {
|
||||
t.Errorf("no-views file body=%q, want raw YAML", rec4.Body.String())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue