diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index dc83314..d1fdf6f 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -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 + // .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 //.{docx,html,pdf}` (virtual file URL, // see RecognizeVirtualConvert). The .md source serves diff --git a/zddc/cmd/zddc-server/main_test.go b/zddc/cmd/zddc-server/main_test.go index 53032e2..d0ea327 100644 --- a/zddc/cmd/zddc-server/main_test.go +++ b/zddc/cmd/zddc-server/main_test.go @@ -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()) + } +}