package handler import ( "html/template" "net/http" "net/url" "os" "path/filepath" "sort" "strings" "codeberg.org/VARASYS/ZDDC/zddc/internal/config" "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" ) // IsProjectRootURL reports whether urlPath names a project root — // exactly one path segment, no trailing slash. Used by the dispatcher // to route / (with no trailing slash) to ServeProjectLanding // instead of 301'ing to the slash form. // // Examples: // // "/Project-1" → true // "/Project-1/" → false (trailing slash → directory listing) // "/Project-1/x" → false (deeper) // "/" → false (deployment root, served by landing tool) // "" → false func IsProjectRootURL(urlPath string) bool { if urlPath == "" || urlPath == "/" { return false } if strings.HasSuffix(urlPath, "/") { return false } trimmed := strings.TrimPrefix(urlPath, "/") return !strings.Contains(trimmed, "/") } // projectLandingTmpl is the inline template for / (no slash). // It's a simple navigation page — four canonical-stage cards, a link // to the full file browser, and instructions for editing the MDL, // listing any parties already present in archive/. var projectLandingTmpl = template.Must(template.New("projectLanding").Parse(` {{.Project}} — ZDDC
{{.Project}}
ZDDC project workspace

{{.Project}} — project workspace

Pick a lifecycle stage, or browse all files.

Browse all files →

Master Deliverables List (MDL)

Each counterparty in the archive has an MDL — an editable table of expected deliverables. The default columns mirror the ZDDC tracking-number components (originator, phase, project, area, discipline, type, sequence, suffix) plus title, plannedRevision, plannedDate, status, and owner.

To edit the MDL for any party:

  1. Open the project archive: /{{.ProjectURL}}/archive/
  2. Click into a party's folder (e.g. PartyA)
  3. Click mdl inside the party folder
{{if .Parties}}

Direct links — parties currently in archive/:

{{else}}

No party folders yet. The MDL view auto-renders at any archive/<party>/mdl/ URL, even when the folder doesn't exist on disk — so you can start editing an MDL before any transmittals have been exchanged.

{{end}}

To customize the columns or schema for a specific party, drop a table.yaml and form.yaml into archive/<party>/mdl/. Operator-supplied files override the embedded defaults entirely.

`)) // projectLandingData is the template input. type projectLandingData struct { Project string ProjectURL string // url-escaped Project, for use in href values ArchiveSeg string // on-disk casing of "archive" (Archive vs archive) Parties []partyEntry } type partyEntry struct { DisplayName string // on-disk name (e.g. "PartyA") URLName string // url-escaped variant } // ServeProjectLanding renders the project root navigation page at // / (no trailing slash). Lists the four canonical lifecycle // stages as cards, links into the full browse view, and provides // instructions for editing the per-party MDL with direct links to any // parties already present under archive/. // // ACL: the dispatcher already gates this entry by the project's // .zddc cascade before calling here. No additional check needed. func ServeProjectLanding(cfg config.Config, w http.ResponseWriter, r *http.Request, project string) { projectAbs := filepath.Join(cfg.Root, project) // On-disk casing of archive/ — preserve in URL hrefs so links // don't bounce through the URL-canonicalisation layer. archiveSeg, _ := zddc.ResolveCanonical(projectAbs, "archive") if archiveSeg == "" { archiveSeg = "archive" } // Enumerate parties under archive//. Failures here are // non-fatal — the page just renders without the direct-link list. var parties []partyEntry if archiveSeg != "" { archiveAbs := filepath.Join(projectAbs, archiveSeg) if entries, err := os.ReadDir(archiveAbs); err == nil { for _, e := range entries { if !e.IsDir() { continue } name := e.Name() if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") { continue } parties = append(parties, partyEntry{ DisplayName: name, URLName: url.PathEscape(name), }) } sort.Slice(parties, func(i, j int) bool { return parties[i].DisplayName < parties[j].DisplayName }) } } data := projectLandingData{ Project: project, ProjectURL: url.PathEscape(project), ArchiveSeg: url.PathEscape(archiveSeg), Parties: parties, } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") // recomputes party list per hit if err := projectLandingTmpl.Execute(w, data); err != nil { // Headers already flushed; nothing to do beyond log. http.Error(w, "Internal Server Error", http.StatusInternalServerError) } }