From 6efe71e573bee27139e52c512bafe537c8540f67 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 28 May 2026 12:37:35 -0500 Subject: [PATCH 1/7] feat(server): edit-history versioning for working-folder markdown A history: true .zddc subtree (enabled by default on archive//working/) routes markdown PUTs through WriteTextWithHistory: each save snapshots the content into a hidden, immutable .history// store (content-addressed blobs + an append-only log.jsonl carrying server-stamped {ts, email, sha, prev}) before writing the live file. The live file at its natural path stays the source of truth; no symlinks, no audit in the body/filename. Reads: GET ?history=1 lists versions (newest-first, current flagged); GET ?history= returns that version's bytes (hex-id guard against traversal). Listings carry a per-file History flag so the browse client knows where to offer the affordance. History is subtree-inheriting and ignores inherit:false ACL fences (versioning is a write behavior, not a permission), so fenced per-user homes under working/ are covered too. No-op saves dedup; pre-existing files lazy-seed their origin version. Records (.yaml) keep their existing in-body-audit history path. Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/cmd/zddc-server/main.go | 22 +- zddc/internal/fs/tree.go | 5 + zddc/internal/handler/fileapi.go | 10 + zddc/internal/handler/history.go | 240 ++++++++++++++++++++++ zddc/internal/handler/mdhistory_test.go | 221 ++++++++++++++++++++ zddc/internal/listing/types.go | 8 + zddc/internal/zddc/cascade.go | 20 ++ zddc/internal/zddc/defaults.zddc.yaml | 7 + zddc/internal/zddc/file.go | 18 ++ zddc/internal/zddc/history_policy_test.go | 58 ++++++ zddc/internal/zddc/lookups.go | 12 ++ 11 files changed, 616 insertions(+), 5 deletions(-) create mode 100644 zddc/internal/handler/mdhistory_test.go create mode 100644 zddc/internal/zddc/history_policy_test.go diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 84389c6..a99ac87 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -1323,11 +1323,23 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps // see RecognizeVirtualConvert). The .md source serves // normally here.) - // Record-history list: GET .yaml?history=1 returns the - // list of prior revisions stored under /.history//. - // ACL already passed (parent-dir chain). Non-record paths fall - // through to the normal file serve. - if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Get("history") == "1" { + // Edit-history: ACL already passed (parent-dir chain). + // - Records (.yaml rows): GET .yaml?history=1 lists prior + // revisions stored under /.history// (audit in-body). + // - Text (markdown) under a history: true subtree: + // ?history=1 lists versions; ?history= returns that version's + // bytes. Audit lives in /.history//log.jsonl. + // Non-history paths fall through to the normal file serve. + if (r.Method == http.MethodGet || r.Method == http.MethodHead) && r.URL.Query().Has("history") { + version := r.URL.Query().Get("history") + if handler.IsTextHistoryCandidate(absPath) { + if chain.EffectiveHistory() { + handler.ServeTextHistory(w, r, absPath, version) + } else { + http.NotFound(w, r) + } + return + } handler.ServeHistoryList(w, r, absPath) return } diff --git a/zddc/internal/fs/tree.go b/zddc/internal/fs/tree.go index a3ee4b0..b898dc7 100644 --- a/zddc/internal/fs/tree.go +++ b/zddc/internal/fs/tree.go @@ -102,6 +102,10 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, principal := zddc.Principal{Email: userEmail, Elevated: elevated} parentActiveAdmin := elevated && userEmail != "" && zddc.IsAdminForChain(parentChain, userEmail) + // Edit-history is a subtree behavior; resolved once for this dir and + // flagged on each eligible (markdown) file so the browse client knows + // where to offer the History/diff affordances. + historyEnabled := parentChain.EffectiveHistory() for _, entry := range entries { name := entry.Name() @@ -189,6 +193,7 @@ func ListDirectory(ctx context.Context, decider policy.Decider, fsRoot, dirPath, writableBit = zddc.VerbA } fi.Writable = fileVerbs.Has(writableBit) || parentActiveAdmin + fi.History = historyEnabled && strings.EqualFold(filepath.Ext(name), ".md") result = append(result, fi) } diff --git a/zddc/internal/handler/fileapi.go b/zddc/internal/handler/fileapi.go index c0d70e3..31b376f 100644 --- a/zddc/internal/handler/fileapi.go +++ b/zddc/internal/handler/fileapi.go @@ -432,6 +432,16 @@ func serveFilePut(cfg config.Config, w http.ResponseWriter, r *http.Request) { } finalBody = res.FinalBody stamped = true + } else if IsTextHistoryCandidate(abs) && zddc.HistoryAt(cfg.Root, filepath.Dir(abs)) { + // History-enabled text (markdown) files: snapshot every save + // into /.history// with a server-stamped audit line, + // then write the live file. The live file at its natural path + // remains the source of truth. + if err := WriteTextWithHistory(abs, body, EmailFromContext(r)); err != nil { + auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } } else { if err := zddc.WriteAtomic(abs, body); err != nil { auditFile(r, "put", cleanURL, http.StatusInternalServerError, len(body), err) diff --git a/zddc/internal/handler/history.go b/zddc/internal/handler/history.go index 5320057..46d2d45 100644 --- a/zddc/internal/handler/history.go +++ b/zddc/internal/handler/history.go @@ -802,3 +802,243 @@ func atoiSafe(s string) int { } return n } + +// ─── Markdown / text edit-history ──────────────────────────────────────── +// +// History-enabled text files (a `history: true` .zddc subtree — see +// zddc.PolicyChain.EffectiveHistory) keep every saved version under +// /.history//. Unlike records, text files can't carry audit +// fields in-body, so authorship + ordering live in a sidecar log: +// +// .history//.md one immutable blob per distinct content +// .history//log.jsonl one MdHistoryEntry per save, in order +// +// Blobs are content-addressed (so reverting to an earlier exact state +// reuses its blob); the live file's content always equals the last log +// entry's sha. Email + timestamp are stamped server-side from the +// authenticated principal — never client-supplied, mirroring the record +// path's anti-forgery stance. + +const mdHistoryLogName = "log.jsonl" + +// MdHistoryEntry is one saved version of a history-tracked text file. +type MdHistoryEntry struct { + Ts string `json:"ts"` // RFC3339Nano UTC of the save + By string `json:"by"` // authenticated principal email ("" if pre-history) + Sha string `json:"sha"` // content hash = version id = blob stem + Prev string `json:"prev,omitempty"` // prior version's sha + Bytes int `json:"bytes"` // size of this version + Current bool `json:"current,omitempty"` // derived by ListMdHistory; never persisted +} + +// mdVersionIDRe validates a client-supplied version id so it can't +// escape the history dir. Version ids are lowercase hex content hashes. +var mdVersionIDRe = regexp.MustCompile(`^[0-9a-f]{1,64}$`) + +// IsTextHistoryCandidate reports whether abs is a text file eligible for +// edit-history versioning. Scoped to markdown for now (the browse editor +// surface); widen here to add .txt etc. +func IsTextHistoryCandidate(abs string) bool { + return strings.EqualFold(filepath.Ext(abs), ".md") +} + +func mdHistoryDir(abs string) string { + return filepath.Join(filepath.Dir(abs), historyDirName, stripExt(filepath.Base(abs))) +} + +// WriteTextWithHistory snapshots prior and new content into +// .history//, appends an audit line, then writes the live file +// atomically. No-op saves (content identical to the current head) don't +// create a version. History is written BEFORE the live file so a crash +// can't lose a version the live write would have superseded. +func WriteTextWithHistory(abs string, body []byte, principalEmail string) error { + histDir := mdHistoryDir(abs) + logPath := filepath.Join(histDir, mdHistoryLogName) + ext := filepath.Ext(abs) + + // Prior live content (nil on create). + var prior []byte + priorSha := "" + priorMtime := "" + if info, err := os.Stat(abs); err == nil && !info.IsDir() { + if data, rerr := os.ReadFile(abs); rerr == nil { + prior = data + priorSha = fileETag(prior) + priorMtime = info.ModTime().UTC().Format(time.RFC3339Nano) + } + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + newSha := fileETag(body) + if principalEmail == "" { + principalEmail = "anonymous" + } + now := time.Now().UTC().Format(time.RFC3339Nano) + + if err := os.MkdirAll(histDir, 0o755); err != nil { + return fmt.Errorf("mkdir history: %w", err) + } + + writeBlob := func(sha string, data []byte) error { + blob := filepath.Join(histDir, sha+ext) + if _, err := os.Stat(blob); errors.Is(err, os.ErrNotExist) { + return zddc.WriteAtomic(blob, data) + } + return nil + } + + logExisted := false + if _, err := os.Stat(logPath); err == nil { + logExisted = true + } + + var entries []MdHistoryEntry + if logExisted { + existing, err := readMdLog(logPath) + if err != nil { + return err + } + entries = existing + } + + // Lazy-seed: a file that pre-existed history enablement has prior + // bytes but no log. Capture that state as the origin version so the + // chain isn't missing its start. Authorship is unknown (""). + if prior != nil && !logExisted { + if err := writeBlob(priorSha, prior); err != nil { + return err + } + ts := priorMtime + if ts == "" { + ts = now + } + entries = append(entries, MdHistoryEntry{Ts: ts, By: "", Sha: priorSha, Bytes: len(prior)}) + } + + lastSha := "" + if len(entries) > 0 { + lastSha = entries[len(entries)-1].Sha + } + + // Record this save unless it's a no-op (identical to the head). + changed := newSha != lastSha + if changed { + if err := writeBlob(newSha, body); err != nil { + return err + } + entries = append(entries, MdHistoryEntry{Ts: now, By: principalEmail, Sha: newSha, Prev: priorSha, Bytes: len(body)}) + } + + // Rewrite the log atomically (CIFS-safe; avoids partial-append + // corruption) only when something actually changed. + if changed || (prior != nil && !logExisted) { + if err := writeMdLog(logPath, entries); err != nil { + return err + } + } + + return zddc.WriteAtomic(abs, body) +} + +func readMdLog(path string) ([]MdHistoryEntry, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + var out []MdHistoryEntry + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + var e MdHistoryEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + continue // skip a malformed line rather than fail the whole read + } + out = append(out, e) + } + return out, nil +} + +func writeMdLog(path string, entries []MdHistoryEntry) error { + var sb strings.Builder + for _, e := range entries { + e.Current = false // never persist the derived flag + b, err := json.Marshal(e) + if err != nil { + return err + } + sb.Write(b) + sb.WriteByte('\n') + } + return zddc.WriteAtomic(path, []byte(sb.String())) +} + +// ListMdHistory returns the saved versions of abs, newest first, with +// Current set on the version whose content matches the live file. +func ListMdHistory(abs string) ([]MdHistoryEntry, error) { + logPath := filepath.Join(mdHistoryDir(abs), mdHistoryLogName) + entries, err := readMdLog(logPath) + if err != nil { + return nil, err + } + liveSha := "" + if data, err := os.ReadFile(abs); err == nil { + liveSha = fileETag(data) + } + sort.SliceStable(entries, func(i, j int) bool { return entries[i].Ts > entries[j].Ts }) + // Mark the newest entry whose content matches the live file as the + // current version. A revert reuses an earlier blob, so the same sha + // can appear twice — only the most recent save is "current". + for i := range entries { + if entries[i].Sha == liveSha { + entries[i].Current = true + break + } + } + return entries, nil +} + +// ServeTextHistory dispatches GET ?history=... for history-enabled +// text files: `?history=1` (or empty / `list`) returns the version list +// as JSON; `?history=` returns that version's raw bytes. ACL on the +// live file has already been checked by the caller. +func ServeTextHistory(w http.ResponseWriter, r *http.Request, abs, version string) { + if !IsTextHistoryCandidate(abs) { + http.NotFound(w, r) + return + } + if version == "" || version == "1" || version == "list" { + entries, err := ListMdHistory(abs) + if err != nil { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("X-ZDDC-Source", "history-list") + _ = json.NewEncoder(w).Encode(entries) + return + } + if !mdVersionIDRe.MatchString(version) { + http.Error(w, "Bad Request — invalid version id", http.StatusBadRequest) + return + } + blob := filepath.Join(mdHistoryDir(abs), version+filepath.Ext(abs)) + data, err := os.ReadFile(blob) + if err != nil { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + w.Header().Set("X-ZDDC-Source", "history-version") + w.Header().Set("X-ZDDC-History-Version", version) + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + _, _ = w.Write(data) +} diff --git a/zddc/internal/handler/mdhistory_test.go b/zddc/internal/handler/mdhistory_test.go new file mode 100644 index 0000000..d5a91b6 --- /dev/null +++ b/zddc/internal/handler/mdhistory_test.go @@ -0,0 +1,221 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "codeberg.org/VARASYS/ZDDC/zddc/internal/zddc" +) + +func mustNoErr(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) + } +} + +func countBlobs(t *testing.T, histDir string) int { + t.Helper() + ents, err := os.ReadDir(histDir) + if err != nil { + t.Fatalf("read history dir: %v", err) + } + n := 0 + for _, e := range ents { + if strings.HasSuffix(e.Name(), ".md") { + n++ + } + } + return n +} + +func TestWriteTextWithHistory_CreateUpdateDedupRestore(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "notes.md") + histDir := filepath.Join(dir, ".history", "notes") + sha1 := fileETag([]byte("v1")) + sha2 := fileETag([]byte("v2")) + + // ── create ── + mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "alice@x.com")) + if b, _ := os.ReadFile(abs); string(b) != "v1" { + t.Fatalf("live = %q, want v1", b) + } + entries, err := ListMdHistory(abs) + mustNoErr(t, err) + if len(entries) != 1 { + t.Fatalf("after create: want 1 entry, got %d", len(entries)) + } + if entries[0].By != "alice@x.com" { + t.Errorf("by = %q, want alice@x.com", entries[0].By) + } + if entries[0].Sha != sha1 { + t.Errorf("sha = %q, want %q", entries[0].Sha, sha1) + } + if !entries[0].Current { + t.Errorf("v1 should be current") + } + if _, err := os.Stat(filepath.Join(histDir, sha1+".md")); err != nil { + t.Errorf("v1 blob missing: %v", err) + } + + // ── update ── + time.Sleep(2 * time.Millisecond) // distinct RFC3339Nano ts for ordering + mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com")) + if b, _ := os.ReadFile(abs); string(b) != "v2" { + t.Fatalf("live = %q, want v2", b) + } + entries, _ = ListMdHistory(abs) + if len(entries) != 2 { + t.Fatalf("after update: want 2 entries, got %d", len(entries)) + } + // newest first + if entries[0].Sha != sha2 || !entries[0].Current { + t.Errorf("head = %+v, want v2 current", entries[0]) + } + if entries[0].Prev != sha1 { + t.Errorf("v2.prev = %q, want %q", entries[0].Prev, sha1) + } + if entries[1].Sha != sha1 || entries[1].Current { + t.Errorf("tail = %+v, want v1 non-current", entries[1]) + } + if entries[1].By != "alice@x.com" { + t.Errorf("v1 author lost: %q", entries[1].By) + } + + // ── no-op save (identical content) → dedup, no new entry ── + mustNoErr(t, WriteTextWithHistory(abs, []byte("v2"), "bob@x.com")) + entries, _ = ListMdHistory(abs) + if len(entries) != 2 { + t.Fatalf("dedup failed: want 2 entries, got %d", len(entries)) + } + + // ── restore v1 content → new log entry, blob reused ── + time.Sleep(2 * time.Millisecond) + mustNoErr(t, WriteTextWithHistory(abs, []byte("v1"), "carol@x.com")) + if b, _ := os.ReadFile(abs); string(b) != "v1" { + t.Fatalf("live = %q, want restored v1", b) + } + entries, _ = ListMdHistory(abs) + if len(entries) != 3 { + t.Fatalf("after restore: want 3 entries, got %d", len(entries)) + } + if entries[0].Sha != sha1 || !entries[0].Current || entries[0].By != "carol@x.com" { + t.Errorf("head = %+v, want restored v1 by carol, current", entries[0]) + } + // Only the newest matching entry is current, even though the oldest + // entry has the same sha. + if entries[2].Current { + t.Errorf("oldest v1 entry should not be current: %+v", entries[2]) + } + // Content-addressed: only two distinct blobs (v1, v2) despite 3 saves. + if n := countBlobs(t, histDir); n != 2 { + t.Errorf("distinct blobs = %d, want 2", n) + } +} + +func TestWriteTextWithHistory_LazySeedPreexisting(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "doc.md") + // Simulate a file that existed before history was enabled. + mustNoErr(t, zddc.WriteAtomic(abs, []byte("legacy"))) + + mustNoErr(t, WriteTextWithHistory(abs, []byte("edited"), "dave@x.com")) + + entries, err := ListMdHistory(abs) + mustNoErr(t, err) + if len(entries) != 2 { + t.Fatalf("lazy-seed: want 2 entries (seeded prior + new), got %d", len(entries)) + } + // newest = the edit; oldest = the seeded legacy version (author unknown) + if entries[0].By != "dave@x.com" || entries[0].Sha != fileETag([]byte("edited")) { + t.Errorf("head = %+v, want edit by dave", entries[0]) + } + if entries[1].By != "" || entries[1].Sha != fileETag([]byte("legacy")) { + t.Errorf("seed = %+v, want legacy with empty author", entries[1]) + } +} + +func TestWriteTextWithHistory_EmptyAuthorAnonymous(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "x.md") + mustNoErr(t, WriteTextWithHistory(abs, []byte("a"), "")) + entries, _ := ListMdHistory(abs) + if len(entries) != 1 || entries[0].By != "anonymous" { + t.Fatalf("empty author should record anonymous, got %+v", entries) + } +} + +func TestServeTextHistory_ListAndVersion(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "page.md") + mustNoErr(t, WriteTextWithHistory(abs, []byte("one"), "a@x.com")) + time.Sleep(2 * time.Millisecond) + mustNoErr(t, WriteTextWithHistory(abs, []byte("two"), "b@x.com")) + sha1 := fileETag([]byte("one")) + + // ── list ── + req := httptest.NewRequest(http.MethodGet, "/page.md?history=1", nil) + rec := httptest.NewRecorder() + ServeTextHistory(rec, req, abs, "1") + if rec.Code != http.StatusOK { + t.Fatalf("list status = %d", rec.Code) + } + var got []MdHistoryEntry + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("list body not JSON: %v", err) + } + if len(got) != 2 || got[0].By != "b@x.com" { + t.Fatalf("list = %+v, want 2 newest-first", got) + } + + // ── specific version content ── + req = httptest.NewRequest(http.MethodGet, "/page.md?history="+sha1, nil) + rec = httptest.NewRecorder() + ServeTextHistory(rec, req, abs, sha1) + if rec.Code != http.StatusOK { + t.Fatalf("version status = %d", rec.Code) + } + if rec.Body.String() != "one" { + t.Errorf("version body = %q, want %q", rec.Body.String(), "one") + } + if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") { + t.Errorf("content-type = %q", ct) + } +} + +func TestServeTextHistory_RejectsTraversalAndBadInput(t *testing.T) { + dir := t.TempDir() + abs := filepath.Join(dir, "p.md") + mustNoErr(t, WriteTextWithHistory(abs, []byte("x"), "a@x.com")) + // Drop a secret in the parent so a successful traversal would be visible. + mustNoErr(t, zddc.WriteAtomic(filepath.Join(dir, "secret"), []byte("TOPSECRET"))) + + for _, bad := range []string{"../secret", "..%2Fsecret", "abc/def", "ZZZ", "deadbeef.md"} { + req := httptest.NewRequest(http.MethodGet, "/p.md?history="+url.QueryEscape(bad), nil) + rec := httptest.NewRecorder() + ServeTextHistory(rec, req, abs, bad) + if rec.Code == http.StatusOK { + t.Errorf("version %q unexpectedly served: body=%q", bad, rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "TOPSECRET") { + t.Fatalf("traversal leaked secret for input %q", bad) + } + } + + // Non-markdown path → 404 (history not applicable). + yamlAbs := filepath.Join(dir, "rec.yaml") + req := httptest.NewRequest(http.MethodGet, "/rec.yaml?history=1", nil) + rec := httptest.NewRecorder() + ServeTextHistory(rec, req, yamlAbs, "1") + if rec.Code != http.StatusNotFound { + t.Errorf("non-md status = %d, want 404", rec.Code) + } +} diff --git a/zddc/internal/listing/types.go b/zddc/internal/listing/types.go index 345eadf..ee11e26 100644 --- a/zddc/internal/listing/types.go +++ b/zddc/internal/listing/types.go @@ -86,4 +86,12 @@ type FileInfo struct { // as "no permissions known" and fall back to a server round-trip // (or just disable affordances) rather than assuming any grant. Verbs string `json:"verbs,omitempty"` + + // History is true when this entry is a text file in a history: true + // cascade subtree — i.e. every save is versioned into the sibling + // .history/ store and GET ?history=1 lists prior versions. The + // browse client uses it to show "History…" / diff affordances only + // where they apply. False/absent for directories, virtual entries, + // and files outside a history-enabled subtree. + History bool `json:"history,omitempty"` } diff --git a/zddc/internal/zddc/cascade.go b/zddc/internal/zddc/cascade.go index afe973d..9d4cf90 100644 --- a/zddc/internal/zddc/cascade.go +++ b/zddc/internal/zddc/cascade.go @@ -47,6 +47,25 @@ func (chain PolicyChain) VisibleStart(toIdx int) int { return 0 } +// EffectiveHistory reports whether edit-history versioning is enabled +// for writes at this chain's directory. Unlike DropTarget (leaf-only), +// history is a subtree behavior: the closest-to-leaf explicit setting +// wins and applies to all descendants. It deliberately IGNORES +// inherit:false ACL fences — versioning is a write behavior, not a +// permission, so a fenced per-user home under a history-enabled +// working/ still records history. Falls back to the embedded defaults. +func (chain PolicyChain) EffectiveHistory() bool { + for i := len(chain.Levels) - 1; i >= 0; i-- { + if v := chain.Levels[i].History; v != nil { + return *v + } + } + if v := chain.Embedded.History; v != nil { + return *v + } + return false +} + // policyCache caches effective policies keyed by dirPath. // Values are PolicyChain. var policyCache sync.Map @@ -370,6 +389,7 @@ func nonZeroZddcFields(zf ZddcFile) []string { add("auto_own_fenced", zf.AutoOwnFenced != nil) add("virtual", zf.Virtual != nil) add("drop_target", zf.DropTarget != nil) + add("history", zf.History != nil) add("worm", zf.Worm != nil) add("available_tools", len(zf.AvailableTools) > 0) add("received_path", zf.ReceivedPath != "") diff --git a/zddc/internal/zddc/defaults.zddc.yaml b/zddc/internal/zddc/defaults.zddc.yaml index 3863f08..4cffa80 100644 --- a/zddc/internal/zddc/defaults.zddc.yaml +++ b/zddc/internal/zddc/defaults.zddc.yaml @@ -475,6 +475,13 @@ paths: # homes below. auto_own: true drop_target: true + # Edit-history: every markdown save under working/ (incl. + # the fenced per-user homes — history inherits through + # fences) is versioned into a sibling .history/ store with + # a server-stamped audit line (who + when). The live file + # stays the source of truth; GET ?history lists prior + # versions. See ZddcFile.History / handler.WriteTextWithHistory. + history: true paths: "*": # per-user home dir, fenced default_tool: browse diff --git a/zddc/internal/zddc/file.go b/zddc/internal/zddc/file.go index e25b13f..ad6d82b 100644 --- a/zddc/internal/zddc/file.go +++ b/zddc/internal/zddc/file.go @@ -263,6 +263,24 @@ type ZddcFile struct { // not its descendants. Defaults (nil): no drop zone. DropTarget *bool `yaml:"drop_target,omitempty" json:"drop_target,omitempty"` + // History enables server-managed edit-history versioning for text + // (markdown) writes in this subtree. When true, each save of a + // history-eligible file (see handler.IsTextHistoryCandidate) snapshots + // the content into /.history// and appends a server-stamped + // audit line (timestamp + authenticated email) to that folder's + // log.jsonl, then writes the live file. The live file at its natural + // path stays the source of truth; the .history/ store is immutable and + // auto-hidden (dot-prefixed). + // + // Unlike DropTarget (leaf-only), History is a SUBTREE behavior: a + // `history: true` at an ancestor (e.g. archive//working/) + // applies to every descendant, and it deliberately inherits through + // inherit:false ACL fences (per-user homes under working/ still record + // history) — versioning is a write behavior, not a permission. A + // deeper level may set `history: false` to opt a subtree out. Resolved + // by PolicyChain.EffectiveHistory. Empty (nil) inherits via cascade. + History *bool `yaml:"history,omitempty" json:"history,omitempty"` + // Worm marks this directory (and its descendants) as // write-once-read-many. A non-nil Worm list — even an empty one — // puts the path into a WORM zone with these effects, applied AFTER diff --git a/zddc/internal/zddc/history_policy_test.go b/zddc/internal/zddc/history_policy_test.go new file mode 100644 index 0000000..c08cb80 --- /dev/null +++ b/zddc/internal/zddc/history_policy_test.go @@ -0,0 +1,58 @@ +package zddc + +import "testing" + +func bptr(b bool) *bool { return &b } + +func TestEffectiveHistory(t *testing.T) { + tests := []struct { + name string + chain PolicyChain + want bool + }{ + { + name: "no levels, no embedded default", + chain: PolicyChain{}, + want: false, + }, + { + name: "embedded default true, no explicit level", + chain: PolicyChain{Levels: []ZddcFile{{}, {}}, Embedded: ZddcFile{History: bptr(true)}}, + want: true, + }, + { + name: "ancestor true inherits down to leaf (nil)", + chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}, {}, {}}}, + want: true, + }, + { + name: "leaf false overrides ancestor true", + chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}, {History: bptr(false)}}}, + want: false, + }, + { + name: "leaf true overrides embedded false", + chain: PolicyChain{Levels: []ZddcFile{{History: bptr(true)}}, Embedded: ZddcFile{History: bptr(false)}}, + want: true, + }, + { + // History must inherit THROUGH an inherit:false ACL fence — + // versioning is a write behavior, not a permission. The fenced + // middle level sets no History, so the ancestor's true wins. + name: "inherits through an inherit:false fence", + chain: PolicyChain{Levels: []ZddcFile{ + {History: bptr(true)}, + {ACL: ACLRules{Inherit: bptr(false)}}, + {}, + }}, + want: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := tc.chain.EffectiveHistory(); got != tc.want { + t.Errorf("EffectiveHistory() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/zddc/internal/zddc/lookups.go b/zddc/internal/zddc/lookups.go index cd1083c..bca0398 100644 --- a/zddc/internal/zddc/lookups.go +++ b/zddc/internal/zddc/lookups.go @@ -147,6 +147,18 @@ func VirtualAt(fsRoot, dirPath string) bool { return false } +// HistoryAt reports whether edit-history versioning is enabled for +// writes in dirPath. Subtree-inheriting (see +// PolicyChain.EffectiveHistory) — a `history: true` at an ancestor +// applies here even through inherit:false fences. +func HistoryAt(fsRoot, dirPath string) bool { + chain, err := EffectivePolicy(fsRoot, dirPath) + if err != nil { + return false + } + return chain.EffectiveHistory() +} + // IsDeclaredPath reports whether dirPath is mentioned in the // cascade — either by an on-disk .zddc at that level OR by any // ancestor's paths: tree (including the embedded defaults). From d00afa1ddc0664dee6ad98a53e0ffc3c657d59ac Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 28 May 2026 12:48:49 -0500 Subject: [PATCH 2/7] fix(server): carry history through the paths-tree merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mergeOverlay (used to thread embedded defaults' paths: tree into chain levels) didn't copy the new History *bool, so EffectiveHistory never saw history: true on archive//working/ — the feature would have silently never triggered. Add the field to the overlay and a HistoryAt defaults test that exercises the real cascade (working/ + fenced homes true; sibling slots false). Co-Authored-By: Claude Opus 4.7 (1M context) --- zddc/internal/zddc/lookups_test.go | 28 ++++++++++++++++++++++++++++ zddc/internal/zddc/walker.go | 3 +++ 2 files changed, 31 insertions(+) diff --git a/zddc/internal/zddc/lookups_test.go b/zddc/internal/zddc/lookups_test.go index 29f7ffa..028f463 100644 --- a/zddc/internal/zddc/lookups_test.go +++ b/zddc/internal/zddc/lookups_test.go @@ -48,6 +48,34 @@ func TestDefaultToolAt_FromEmbeddedConvention(t *testing.T) { } } +// TestHistoryAt_Defaults — the embedded convention enables edit-history +// versioning on archive//working/ and (because history is +// subtree-inheriting and ignores the auto_own_fenced homes' inherit:false) +// on the per-user homes and any depth beneath them. Sibling slots do not +// get history. +func TestHistoryAt_Defaults(t *testing.T) { + resetCache() + root := t.TempDir() + cases := []struct { + path string + want bool + }{ + {filepath.Join(root, "Project-X", "archive", "Acme", "working"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "working", "alice@example.com", "notes"), true}, + {filepath.Join(root, "Project-X", "archive", "Acme", "mdl"), false}, + {filepath.Join(root, "Project-X", "archive", "Acme", "incoming"), false}, + {filepath.Join(root, "Project-X", "archive", "Acme", "staging"), false}, + {filepath.Join(root, "Project-X", "archive", "Acme", "received"), false}, + {filepath.Join(root, "Project-X", "archive"), false}, + } + for _, tc := range cases { + if got := HistoryAt(root, tc.path); got != tc.want { + t.Errorf("HistoryAt(%q) = %v, want %v", tc.path[len(root):], got, tc.want) + } + } +} + // TestDirToolAt — the trailing-slash form floors at "browse" for // every path (the embedded convention sets dir_tool nowhere), and an // on-disk .zddc can override it for a subtree. diff --git a/zddc/internal/zddc/walker.go b/zddc/internal/zddc/walker.go index a4e84a5..a0328b3 100644 --- a/zddc/internal/zddc/walker.go +++ b/zddc/internal/zddc/walker.go @@ -88,6 +88,9 @@ func mergeOverlay(base, top ZddcFile) ZddcFile { if top.DropTarget != nil { out.DropTarget = top.DropTarget } + if top.History != nil { + out.History = top.History + } // Worm: presence (non-nil, even empty) marks the WORM zone. // Concat-dedupe across levels (a deeper .zddc adds controllers); // preserve a non-nil empty slice so `worm: []` survives the From 9972e6773ad6bd97757b17ff906c78cb99a74126 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 28 May 2026 12:49:00 -0500 Subject: [PATCH 3/7] feat(browse): markdown version-history viewer with diff + restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "History…" context-menu item on markdown files in a history:true subtree (server mode only — the audit is server-stamped). It opens a modal that lists every saved version newest-first (timestamp + author + size, current flagged), lets you View any version, Diff any two, and Restore one (a forward PUT — non-destructive). - shared/diff.js: dependency-free line/word LCS diff (window.zddc.diff), prefix/suffix trimming + a cell cap so large files don't stall the UI. - browse/js/history.js: the modal (list / view / diff / restore), talking to GET ?history=1 and ?history=. - loader.js carries the per-file history flag; events.js adds the menu item. - Wired diff.js + history.js + history.css into browse/build.sh; diff.js into the zddc-test.html shim. tests/diff.spec.js covers the diff algorithm. Co-Authored-By: Claude Opus 4.7 (1M context) --- browse/build.sh | 3 + browse/css/history.css | 131 ++++++++++++++ browse/js/events.js | 18 ++ browse/js/history.js | 396 +++++++++++++++++++++++++++++++++++++++++ browse/js/loader.js | 6 + playwright.config.js | 4 + shared/diff.js | 108 +++++++++++ shared/zddc-test.html | 3 +- tests/diff.spec.js | 71 ++++++++ 9 files changed, 739 insertions(+), 1 deletion(-) create mode 100644 browse/css/history.css create mode 100644 browse/js/history.js create mode 100644 shared/diff.js create mode 100644 tests/diff.spec.js diff --git a/browse/build.sh b/browse/build.sh index 937c6de..b0a5a56 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -32,6 +32,7 @@ concat_files \ "css/base.css" \ "css/tree.css" \ "css/preview-yaml.css" \ + "css/history.css" \ > "$css_temp" # JS files: shared canonical helpers, then browse modules. @@ -47,6 +48,7 @@ concat_files \ "../shared/vendor/toastui-editor-all.min.js" \ "../shared/zddc.js" \ "../shared/zddc-filter.js" \ + "../shared/diff.js" \ "../shared/zip-source.js" \ "../shared/theme.js" \ "../shared/toast.js" \ @@ -71,6 +73,7 @@ concat_files \ "js/plan-review.js" \ "js/accept-transmittal.js" \ "js/stage.js" \ + "js/history.js" \ "js/create-transmittal.js" \ "js/events.js" \ "js/app.js" \ diff --git a/browse/css/history.css b/browse/css/history.css new file mode 100644 index 0000000..7e23771 --- /dev/null +++ b/browse/css/history.css @@ -0,0 +1,131 @@ +/* history.css — markdown edit-history modal (browse/js/history.js). */ + +.md-history-box { + background: var(--bg, #fff); + color: var(--fg, #111); + padding: 1.1rem 1.35rem; + border-radius: 6px; + min-width: 30rem; + max-width: 56rem; + width: 80vw; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); + font-family: inherit; +} + +.md-history-title { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + overflow-wrap: anywhere; +} + +.md-history-body { + display: flex; + flex-direction: column; + min-height: 0; /* allow inner scroll regions to shrink */ + overflow: hidden; +} + +.md-history-hint { + margin: 0 0 0.6rem 0; + font-size: 0.82rem; + color: var(--muted, #666); +} + +.md-history-empty { + margin: 1rem 0; + font-size: 0.9rem; + color: var(--muted, #666); +} + +/* ── version list ── */ +.md-history-list { + overflow-y: auto; + border: 1px solid var(--border, #ddd); + border-radius: 4px; +} + +.md-history-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border, #eee); + font-size: 0.88rem; +} + +.md-history-row:last-child { border-bottom: none; } +.md-history-row.is-current { background: var(--accent-bg, rgba(60, 130, 246, 0.08)); } + +.md-history-meta { + display: flex; + align-items: baseline; + gap: 0.75rem; + min-width: 0; +} + +.md-history-time { font-variant-numeric: tabular-nums; } +.md-history-by { color: var(--muted, #555); overflow-wrap: anywhere; } +.md-history-size { color: var(--muted, #888); font-size: 0.8rem; } + +.md-history-badge { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.05rem 0.4rem; + border-radius: 10px; + background: var(--accent, #3c82f6); + color: #fff; +} + +.md-history-actions { display: flex; gap: 0.35rem; } + +/* ── single-version view ── */ +.md-history-pre { + flex: 1 1 auto; + overflow: auto; + margin: 0; + padding: 0.6rem 0.8rem; + border: 1px solid var(--border, #ddd); + border-radius: 4px; + background: var(--code-bg, #f7f7f8); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ── diff view ── */ +.md-diff { + flex: 1 1 auto; + overflow: auto; + border: 1px solid var(--border, #ddd); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.md-diff-line { display: flex; gap: 0.5rem; padding: 0 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; } +.md-diff-gutter { flex: 0 0 1ch; text-align: center; color: var(--muted, #999); user-select: none; } +.md-diff-text { flex: 1 1 auto; } + +.md-diff-add { background: rgba(46, 160, 67, 0.16); } +.md-diff-add .md-diff-gutter { color: #2ea043; } +.md-diff-del { background: rgba(248, 81, 73, 0.16); } +.md-diff-del .md-diff-gutter { color: #f85149; } +.md-diff-eq { color: var(--muted, #777); } + +.md-diff-old { color: #f85149; } +.md-diff-new { color: #2ea043; } + +/* ── footer ── */ +.md-history-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.85rem; +} diff --git a/browse/js/events.js b/browse/js/events.js index f6ba823..3b1b52c 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -1061,6 +1061,24 @@ if (s) s.invokeUnstage(c.node); } }, + // ── Version history (history:true subtree, real files only) ── + // Server-mode only: the audit trail (who saved when) is + // server-stamped, so there's no offline equivalent. node.history + // is set by the listing when this file sits in a history-enabled + // cascade subtree (working/). + { + label: 'History…', + icon: '🕘', + visible: function (c) { + if (!serverMode) return false; + if (c.node.isDir || c.node.isZip || c.node.virtual) return false; + return !!c.node.history; + }, + action: function (c) { + var h = window.app.modules.history; + if (h) h.open(c.node); + } + }, { separator: true }, // ── View ── diff --git a/browse/js/history.js b/browse/js/history.js new file mode 100644 index 0000000..e4747b8 --- /dev/null +++ b/browse/js/history.js @@ -0,0 +1,396 @@ +// history.js — markdown edit-history viewer for the browse tool. +// +// Surfaced by events.js as a "History…" right-click item on files in a +// history:true cascade subtree (working/). Server mode only — the audit +// trail (who saved when) is stamped server-side, so there's no offline +// equivalent. +// +// Talks to the zddc-server history endpoints on the file's own URL: +// GET ?history=1 → JSON [{ts, by, sha, prev, bytes, current}] +// GET ?history= → that version's raw bytes +// Restore re-PUTs a chosen version's bytes to , which the server +// records as a new version (forward-only; never destructive). +// +// Diffs are computed client-side via window.zddc.diff (shared/diff.js). + +(function () { + 'use strict'; + + function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function toast(msg, kind) { + if (window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast(msg, kind || 'info'); + } + } + + // Append ?history= (or &history=) to a file URL. + function histURL(baseURL, v) { + var sep = baseURL.indexOf('?') === -1 ? '?' : '&'; + return baseURL + sep + 'history=' + encodeURIComponent(v); + } + + function fmtTime(ts) { + var d = new Date(ts); + if (isNaN(d.getTime())) return ts || ''; + return d.toLocaleString(); + } + + function fmtBytes(n) { + if (n == null) return ''; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / (1024 * 1024)).toFixed(1) + ' MB'; + } + + // Can the principal write (restore) to this file? Mirrors the + // events.js Rename/Delete gating: verbs===undefined means a non-zddc + // backend (no cascade signal) → allow the attempt; otherwise check w. + function canRestore(node) { + if (!node || !node.url) return false; + if (!window.zddc || !window.zddc.cap) return true; + if (typeof node.verbs !== 'string') return true; + return window.zddc.cap.has(node, 'w'); + } + + async function fetchList(node) { + var resp = await fetch(histURL(node.url, '1'), { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + var data = await resp.json(); + return Array.isArray(data) ? data : []; + } + + async function fetchVersion(node, sha) { + var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return await resp.text(); + } + + // ── Modal shell ────────────────────────────────────────────────────── + // One overlay; its body is swapped between the list, a diff, and a + // single-version view. Returns { overlay, body, setTitle, close }. + function makeModal(titleText) { + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay md-history-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + + var box = document.createElement('div'); + box.className = 'md-history-box'; + + var title = document.createElement('h2'); + title.className = 'md-history-title'; + title.textContent = titleText; + + var body = document.createElement('div'); + body.className = 'md-history-body'; + + box.appendChild(title); + box.appendChild(body); + overlay.appendChild(box); + document.body.appendChild(overlay); + + function close() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + document.removeEventListener('keydown', onKey); + } + function onKey(e) { if (e.key === 'Escape') close(); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('mousedown', function (e) { + if (e.target === overlay) close(); + }); + + return { + overlay: overlay, + body: body, + setTitle: function (t) { title.textContent = t; }, + close: close + }; + } + + function footerBar() { + var f = document.createElement('div'); + f.className = 'md-history-footer'; + return f; + } + + function button(label, opts) { + opts = opts || {}; + var b = document.createElement('button'); + b.type = 'button'; + b.textContent = label; + if (opts.primary) b.className = 'btn-primary'; + if (opts.disabled) b.disabled = true; + if (opts.onClick) b.addEventListener('click', opts.onClick); + return b; + } + + // ── List view ────────────────────────────────────────────────────── + function renderList(modal, node, entries) { + modal.setTitle('History — ' + node.name); + var body = modal.body; + body.innerHTML = ''; + + if (!entries.length) { + var empty = document.createElement('p'); + empty.className = 'md-history-empty'; + empty.textContent = 'No saved versions yet. Each save of this file is recorded here.'; + body.appendChild(empty); + var f0 = footerBar(); + f0.appendChild(button('Close', { onClick: modal.close })); + body.appendChild(f0); + return; + } + + var hint = document.createElement('p'); + hint.className = 'md-history-hint'; + hint.textContent = 'Newest first. Select two versions to diff.'; + body.appendChild(hint); + + var list = document.createElement('div'); + list.className = 'md-history-list'; + var selected = []; // shas, in click order (max 2) + + var diffBtn; + function syncDiffBtn() { + if (diffBtn) diffBtn.disabled = selected.length !== 2; + } + + entries.forEach(function (ent) { + var row = document.createElement('div'); + row.className = 'md-history-row' + (ent.current ? ' is-current' : ''); + + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'md-history-pick'; + cb.addEventListener('change', function () { + if (cb.checked) { + selected.push(ent.sha); + // Keep at most two: drop the oldest selection. + if (selected.length > 2) { + var dropped = selected.shift(); + var others = list.querySelectorAll('.md-history-pick'); + others.forEach(function (o, i) { + if (o !== cb && entries[i] && entries[i].sha === dropped) o.checked = false; + }); + } + } else { + selected = selected.filter(function (s) { return s !== ent.sha; }); + } + syncDiffBtn(); + }); + + var meta = document.createElement('div'); + meta.className = 'md-history-meta'; + meta.innerHTML = + '' + escapeHtml(fmtTime(ent.ts)) + '' + + '' + escapeHtml(ent.by || '—') + '' + + '' + escapeHtml(fmtBytes(ent.bytes)) + '' + + (ent.current ? 'current' : ''); + + var actions = document.createElement('div'); + actions.className = 'md-history-actions'; + actions.appendChild(button('View', { + onClick: function () { renderView(modal, node, ent, entries); } + })); + if (!ent.current && canRestore(node)) { + actions.appendChild(button('Restore', { + onClick: function () { restore(modal, node, ent); } + })); + } + + row.appendChild(cb); + row.appendChild(meta); + row.appendChild(actions); + list.appendChild(row); + }); + + body.appendChild(list); + + var f = footerBar(); + diffBtn = button('Diff selected', { + primary: true, disabled: true, + onClick: function () { + if (selected.length !== 2) return; + // Order oldest→newest by the entries' position (newest + // first in the list), so the diff reads old → new. + var picks = entries.filter(function (e) { return selected.indexOf(e.sha) !== -1; }); + picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); }); + renderDiff(modal, node, picks[0], picks[1], entries); + } + }); + f.appendChild(diffBtn); + f.appendChild(button('Close', { onClick: modal.close })); + body.appendChild(f); + } + + // ── Single-version view ────────────────────────────────────────────── + async function renderView(modal, node, ent, entries) { + modal.setTitle('Version — ' + fmtTime(ent.ts)); + var body = modal.body; + body.innerHTML = '

Loading…

'; + var text; + try { + text = await fetchVersion(node, ent.sha); + } catch (e) { + body.innerHTML = ''; + var err = document.createElement('p'); + err.className = 'md-history-empty'; + err.textContent = 'Could not load this version: ' + (e.message || e); + body.appendChild(err); + return; + } + body.innerHTML = ''; + var meta = document.createElement('p'); + meta.className = 'md-history-hint'; + meta.textContent = (ent.by || '—') + ' · ' + fmtTime(ent.ts); + body.appendChild(meta); + + var pre = document.createElement('pre'); + pre.className = 'md-history-pre'; + pre.textContent = text; + body.appendChild(pre); + + var f = footerBar(); + f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } })); + if (!ent.current && canRestore(node)) { + f.appendChild(button('Restore this version', { + primary: true, onClick: function () { restore(modal, node, ent); } + })); + } + body.appendChild(f); + } + + // ── Diff view ───────────────────────────────────────────────────────── + async function renderDiff(modal, node, oldEnt, newEnt, entries) { + modal.setTitle('Diff'); + var body = modal.body; + body.innerHTML = '

Loading…

'; + var oldText, newText; + try { + oldText = await fetchVersion(node, oldEnt.sha); + newText = await fetchVersion(node, newEnt.sha); + } catch (e) { + body.innerHTML = ''; + var err = document.createElement('p'); + err.className = 'md-history-empty'; + err.textContent = 'Could not load versions: ' + (e.message || e); + body.appendChild(err); + return; + } + body.innerHTML = ''; + + var hdr = document.createElement('p'); + hdr.className = 'md-history-hint'; + hdr.innerHTML = + '' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '' + + ' → ' + + '' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + ''; + body.appendChild(hdr); + + var ops = (window.zddc && window.zddc.diff) + ? window.zddc.diff.lines(oldText, newText) + : null; + + var pane = document.createElement('div'); + pane.className = 'md-diff'; + if (!ops) { + pane.textContent = 'Diff unavailable (diff module not loaded).'; + } else { + var unchanged = true; + ops.forEach(function (op) { + if (op.type !== 'eq') unchanged = false; + var line = document.createElement('div'); + line.className = 'md-diff-line md-diff-' + op.type; + var gutter = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' '); + var g = document.createElement('span'); + g.className = 'md-diff-gutter'; + g.textContent = gutter; + var t = document.createElement('span'); + t.className = 'md-diff-text'; + t.textContent = op.text; + line.appendChild(g); + line.appendChild(t); + pane.appendChild(line); + }); + if (unchanged) { + var same = document.createElement('div'); + same.className = 'md-diff-line md-diff-eq'; + same.textContent = '(no differences)'; + pane.appendChild(same); + } + } + body.appendChild(pane); + + if (window.zddc && window.zddc.diff && ops) { + var s = window.zddc.diff.stats(ops); + var statline = document.createElement('p'); + statline.className = 'md-history-hint'; + statline.textContent = '+' + s.added + ' / −' + s.removed; + body.appendChild(statline); + } + + var f = footerBar(); + f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } })); + body.appendChild(f); + } + + // ── Restore ─────────────────────────────────────────────────────────── + async function restore(modal, node, ent) { + if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) { + return; + } + try { + var text = await fetchVersion(node, ent.sha); + var resp = await fetch(node.url, { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'text/markdown' }, + body: text + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + toast('Restored version from ' + fmtTime(ent.ts), 'success'); + // Reflect the new head: refetch the list. + var entries = await fetchList(node); + renderList(modal, node, entries); + // If the file is open in the preview pane, reload it. + var preview = window.app && window.app.modules && window.app.modules.preview; + if (preview && typeof preview.showFilePreview === 'function') { + try { preview.showFilePreview(node); } catch (_e) { /* best effort */ } + } + } catch (e) { + toast('Restore failed: ' + (e.message || e), 'error'); + } + } + + // ── Entry point ───────────────────────────────────────────────────── + async function open(node) { + if (!node || !node.url) { + toast('History is only available in server mode.', 'error'); + return; + } + var modal = makeModal('History — ' + node.name); + modal.body.innerHTML = '

Loading…

'; + try { + var entries = await fetchList(node); + renderList(modal, node, entries); + } catch (e) { + modal.body.innerHTML = ''; + var err = document.createElement('p'); + err.className = 'md-history-empty'; + err.textContent = 'Could not load history: ' + (e.message || e); + modal.body.appendChild(err); + var f = footerBar(); + f.appendChild(button('Close', { onClick: modal.close })); + modal.body.appendChild(f); + } + } + + window.app.modules.history = { open: open }; +})(); diff --git a/browse/js/loader.js b/browse/js/loader.js index 18d7045..5b58589 100644 --- a/browse/js/loader.js +++ b/browse/js/loader.js @@ -60,6 +60,12 @@ // whatever the server enforces on the // actual PUT/DELETE still apply. verbs: typeof e.verbs === 'string' ? e.verbs : undefined, + // Server-computed: true when this file lives in a history:true + // cascade subtree, so every save is versioned and + // GET ?history lists prior versions. Drives the "History…" + // context-menu affordance (server mode only — offline has no + // authenticated identity to attribute saves to). + history: !!e.history, // FS-API specific (null in server mode): handle: null }; diff --git a/playwright.config.js b/playwright.config.js index 8199f31..e52dc4e 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -75,6 +75,10 @@ export default defineConfig({ name: 'zddc', testMatch: 'zddc.spec.js', }, + { + name: 'diff', + testMatch: 'diff.spec.js', + }, { name: 'form-safety', testMatch: 'form-safety.spec.js', diff --git a/shared/diff.js b/shared/diff.js new file mode 100644 index 0000000..bb0a7ed --- /dev/null +++ b/shared/diff.js @@ -0,0 +1,108 @@ +/* + * shared/diff.js — a small, dependency-free text diff. + * + * Attaches to window.zddc.diff. Must load AFTER shared/zddc.js (which + * creates the window.zddc object). Used by the browse tool's markdown + * version-history viewer to show what changed between any two saved + * versions; kept in shared/ so other tools can reuse it. + * + * API: + * window.zddc.diff.lines(oldStr, newStr) + * → [{ type: 'eq'|'del'|'add', text }] line-level diff (LCS) + * window.zddc.diff.words(oldStr, newStr) + * → [{ type: 'eq'|'del'|'add', text }] token-level diff for one + * changed line (whitespace-preserving), for intra-line highlights + * window.zddc.diff.stats(ops) → { added, removed } + * + * The line diff trims the common prefix/suffix before running the O(n*m) + * LCS dynamic program, so a small edit in a large file stays cheap. A + * safety cap falls back to "replace whole block" when the changed middle + * is pathologically large, so the UI never freezes. + */ +(function () { + 'use strict'; + + var LCS_CELL_CAP = 4000000; // ~4M cells (n*m) before the fallback + + function splitLines(s) { + return String(s == null ? '' : s).replace(/\r\n/g, '\n').split('\n'); + } + + // LCS diff of two arrays of strings → ordered [{type, text}] ops. + function lcsDiff(a, b) { + var n = a.length, m = b.length; + if (n === 0 && m === 0) return []; + if (n === 0) return b.map(function (t) { return { type: 'add', text: t }; }); + if (m === 0) return a.map(function (t) { return { type: 'del', text: t }; }); + + if (n * m > LCS_CELL_CAP) { + // Too large to diff finely without risking a UI stall: treat + // the whole block as a wholesale replacement. + var out = a.map(function (t) { return { type: 'del', text: t }; }); + return out.concat(b.map(function (t) { return { type: 'add', text: t }; })); + } + + // dp[i][j] = LCS length of a[i:] and b[j:]. + var dp = new Array(n + 1); + for (var i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0); + for (var ii = n - 1; ii >= 0; ii--) { + for (var jj = m - 1; jj >= 0; jj--) { + if (a[ii] === b[jj]) dp[ii][jj] = dp[ii + 1][jj + 1] + 1; + else dp[ii][jj] = Math.max(dp[ii + 1][jj], dp[ii][jj + 1]); + } + } + + var ops = [], i = 0, j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { ops.push({ type: 'eq', text: a[i] }); i++; j++; } + else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i] }); i++; } + else { ops.push({ type: 'add', text: b[j] }); j++; } + } + while (i < n) ops.push({ type: 'del', text: a[i++] }); + while (j < m) ops.push({ type: 'add', text: b[j++] }); + return ops; + } + + function diffLines(oldStr, newStr) { + var a = splitLines(oldStr), b = splitLines(newStr); + var ops = []; + + // Common prefix. + var start = 0; + while (start < a.length && start < b.length && a[start] === b[start]) start++; + // Common suffix (not overlapping the prefix). + var endA = a.length, endB = b.length; + while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; } + + for (var p = 0; p < start; p++) ops.push({ type: 'eq', text: a[p] }); + var mid = lcsDiff(a.slice(start, endA), b.slice(start, endB)); + for (var k = 0; k < mid.length; k++) ops.push(mid[k]); + for (var s = endA; s < a.length; s++) ops.push({ type: 'eq', text: a[s] }); + return ops; + } + + // Whitespace-preserving tokenization: words and the runs of + // whitespace between them are separate tokens, so a re-diff lines up + // on word boundaries while keeping the original spacing renderable. + function tokenize(s) { + return String(s == null ? '' : s).split(/(\s+)/).filter(function (x) { return x !== ''; }); + } + + function diffWords(oldStr, newStr) { + return lcsDiff(tokenize(oldStr), tokenize(newStr)); + } + + function stats(ops) { + var added = 0, removed = 0; + for (var i = 0; i < ops.length; i++) { + if (ops[i].type === 'add') added++; + else if (ops[i].type === 'del') removed++; + } + return { added: added, removed: removed }; + } + + if (!window.zddc) { + throw new Error('shared/diff.js: window.zddc must be loaded first'); + } + window.zddc.diff = { lines: diffLines, words: diffWords, stats: stats }; +})(); diff --git a/shared/zddc-test.html b/shared/zddc-test.html index bdd6c5f..721495d 100644 --- a/shared/zddc-test.html +++ b/shared/zddc-test.html @@ -2,8 +2,9 @@ ZDDC library test shim - + + diff --git a/tests/diff.spec.js b/tests/diff.spec.js new file mode 100644 index 0000000..6eb013b --- /dev/null +++ b/tests/diff.spec.js @@ -0,0 +1,71 @@ +/** + * Tests for shared/diff.js — the dependency-free text diff used by the + * browse tool's markdown version-history viewer. + * + * Runs against the same shim as zddc.spec.js (shared/zddc-test.html, + * which loads shared/diff.js and exposes window.zddc.diff). + */ + +import { test, expect } from '@playwright/test'; +import * as path from 'path'; + +const SHIM_PATH = 'file://' + path.resolve('shared/zddc-test.html'); + +async function diff(page, fn, ...args) { + return page.evaluate( + ([fn, args]) => window.zddc.diff[fn](...args), + [fn, args] + ); +} + +test.beforeEach(async ({ page }) => { + await page.goto(SHIM_PATH, { waitUntil: 'load' }); +}); + +test('diff module is attached to window.zddc', async ({ page }) => { + const present = await page.evaluate(() => + !!(window.zddc && window.zddc.diff && + typeof window.zddc.diff.lines === 'function' && + typeof window.zddc.diff.words === 'function')); + expect(present).toBe(true); +}); + +test('identical text produces only eq ops', async ({ page }) => { + const ops = await diff(page, 'lines', 'a\nb\nc', 'a\nb\nc'); + expect(ops.every(o => o.type === 'eq')).toBe(true); +}); + +test('a changed middle line shows del then add', async ({ page }) => { + const ops = await diff(page, 'lines', 'a\nb\nc', 'a\nB\nc'); + const compact = ops.map(o => `${o.type}:${o.text}`).join('|'); + expect(compact).toContain('eq:a'); + expect(compact).toContain('del:b'); + expect(compact).toContain('add:B'); + expect(compact).toContain('eq:c'); +}); + +test('stats count added and removed lines', async ({ page }) => { + const addStats = await diff(page, 'stats', await diff(page, 'lines', 'a\nb', 'a\nx\nb')); + expect(addStats).toEqual({ added: 1, removed: 0 }); + + const delStats = await diff(page, 'stats', await diff(page, 'lines', 'a\nb\nc', 'a\nc')); + expect(delStats).toEqual({ added: 0, removed: 1 }); +}); + +test('pure insertion at end', async ({ page }) => { + const ops = await diff(page, 'lines', 'one\ntwo', 'one\ntwo\nthree'); + const added = ops.filter(o => o.type === 'add').map(o => o.text); + expect(added).toEqual(['three']); + expect(ops.filter(o => o.type === 'del')).toHaveLength(0); +}); + +test('word diff aligns on word boundaries, preserving spaces', async ({ page }) => { + const ops = await diff(page, 'words', 'the quick fox', 'the slow fox'); + const changed = ops.filter(o => o.type !== 'eq').map(o => `${o.type}:${o.text}`); + expect(changed).toEqual(['del:quick', 'add:slow']); +}); + +test('empty inputs do not throw', async ({ page }) => { + const ops = await diff(page, 'lines', '', ''); + expect(Array.isArray(ops)).toBe(true); +}); From e58e66a49c23e66ab816a9592e5f8164431d27c3 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 28 May 2026 14:20:21 -0500 Subject: [PATCH 4/7] chore(embedded): cut v0.0.25-beta --- zddc/internal/apps/embedded/archive.html | 2 +- zddc/internal/apps/embedded/browse.html | 664 ++++++++++++++++++- zddc/internal/apps/embedded/classifier.html | 2 +- zddc/internal/apps/embedded/index.html | 2 +- zddc/internal/apps/embedded/transmittal.html | 2 +- zddc/internal/apps/embedded/versions.txt | 14 +- zddc/internal/handler/tables.html | 2 +- 7 files changed, 675 insertions(+), 13 deletions(-) diff --git a/zddc/internal/apps/embedded/archive.html b/zddc/internal/apps/embedded/archive.html index c39a872..b0d1189 100644 --- a/zddc/internal/apps/embedded/archive.html +++ b/zddc/internal/apps/embedded/archive.html @@ -2582,7 +2582,7 @@ td[data-field="trackingNumber"] {
ZDDC Archive - v0.0.24 + v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
diff --git a/zddc/internal/apps/embedded/browse.html b/zddc/internal/apps/embedded/browse.html index 237e74b..5334384 100644 --- a/zddc/internal/apps/embedded/browse.html +++ b/zddc/internal/apps/embedded/browse.html @@ -2329,6 +2329,138 @@ body { max-width: 32rem; } +/* history.css — markdown edit-history modal (browse/js/history.js). */ + +.md-history-box { + background: var(--bg, #fff); + color: var(--fg, #111); + padding: 1.1rem 1.35rem; + border-radius: 6px; + min-width: 30rem; + max-width: 56rem; + width: 80vw; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.25); + font-family: inherit; +} + +.md-history-title { + margin: 0 0 0.5rem 0; + font-size: 1.1rem; + overflow-wrap: anywhere; +} + +.md-history-body { + display: flex; + flex-direction: column; + min-height: 0; /* allow inner scroll regions to shrink */ + overflow: hidden; +} + +.md-history-hint { + margin: 0 0 0.6rem 0; + font-size: 0.82rem; + color: var(--muted, #666); +} + +.md-history-empty { + margin: 1rem 0; + font-size: 0.9rem; + color: var(--muted, #666); +} + +/* ── version list ── */ +.md-history-list { + overflow-y: auto; + border: 1px solid var(--border, #ddd); + border-radius: 4px; +} + +.md-history-row { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border, #eee); + font-size: 0.88rem; +} + +.md-history-row:last-child { border-bottom: none; } +.md-history-row.is-current { background: var(--accent-bg, rgba(60, 130, 246, 0.08)); } + +.md-history-meta { + display: flex; + align-items: baseline; + gap: 0.75rem; + min-width: 0; +} + +.md-history-time { font-variant-numeric: tabular-nums; } +.md-history-by { color: var(--muted, #555); overflow-wrap: anywhere; } +.md-history-size { color: var(--muted, #888); font-size: 0.8rem; } + +.md-history-badge { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 0.05rem 0.4rem; + border-radius: 10px; + background: var(--accent, #3c82f6); + color: #fff; +} + +.md-history-actions { display: flex; gap: 0.35rem; } + +/* ── single-version view ── */ +.md-history-pre { + flex: 1 1 auto; + overflow: auto; + margin: 0; + padding: 0.6rem 0.8rem; + border: 1px solid var(--border, #ddd); + border-radius: 4px; + background: var(--code-bg, #f7f7f8); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +/* ── diff view ── */ +.md-diff { + flex: 1 1 auto; + overflow: auto; + border: 1px solid var(--border, #ddd); + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.82rem; + line-height: 1.45; +} + +.md-diff-line { display: flex; gap: 0.5rem; padding: 0 0.5rem; white-space: pre-wrap; overflow-wrap: anywhere; } +.md-diff-gutter { flex: 0 0 1ch; text-align: center; color: var(--muted, #999); user-select: none; } +.md-diff-text { flex: 1 1 auto; } + +.md-diff-add { background: rgba(46, 160, 67, 0.16); } +.md-diff-add .md-diff-gutter { color: #2ea043; } +.md-diff-del { background: rgba(248, 81, 73, 0.16); } +.md-diff-del .md-diff-gutter { color: #f85149; } +.md-diff-eq { color: var(--muted, #777); } + +.md-diff-old { color: #f85149; } +.md-diff-new { color: #2ea043; } + +/* ── footer ── */ +.md-history-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.85rem; +} + @@ -2344,7 +2476,7 @@ body {
ZDDC Browse - v0.0.24 + v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
@@ -4227,6 +4359,115 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr window.zddc.filter = { parse: parse, matches: matches }; })(); +/* + * shared/diff.js — a small, dependency-free text diff. + * + * Attaches to window.zddc.diff. Must load AFTER shared/zddc.js (which + * creates the window.zddc object). Used by the browse tool's markdown + * version-history viewer to show what changed between any two saved + * versions; kept in shared/ so other tools can reuse it. + * + * API: + * window.zddc.diff.lines(oldStr, newStr) + * → [{ type: 'eq'|'del'|'add', text }] line-level diff (LCS) + * window.zddc.diff.words(oldStr, newStr) + * → [{ type: 'eq'|'del'|'add', text }] token-level diff for one + * changed line (whitespace-preserving), for intra-line highlights + * window.zddc.diff.stats(ops) → { added, removed } + * + * The line diff trims the common prefix/suffix before running the O(n*m) + * LCS dynamic program, so a small edit in a large file stays cheap. A + * safety cap falls back to "replace whole block" when the changed middle + * is pathologically large, so the UI never freezes. + */ +(function () { + 'use strict'; + + var LCS_CELL_CAP = 4000000; // ~4M cells (n*m) before the fallback + + function splitLines(s) { + return String(s == null ? '' : s).replace(/\r\n/g, '\n').split('\n'); + } + + // LCS diff of two arrays of strings → ordered [{type, text}] ops. + function lcsDiff(a, b) { + var n = a.length, m = b.length; + if (n === 0 && m === 0) return []; + if (n === 0) return b.map(function (t) { return { type: 'add', text: t }; }); + if (m === 0) return a.map(function (t) { return { type: 'del', text: t }; }); + + if (n * m > LCS_CELL_CAP) { + // Too large to diff finely without risking a UI stall: treat + // the whole block as a wholesale replacement. + var out = a.map(function (t) { return { type: 'del', text: t }; }); + return out.concat(b.map(function (t) { return { type: 'add', text: t }; })); + } + + // dp[i][j] = LCS length of a[i:] and b[j:]. + var dp = new Array(n + 1); + for (var i = 0; i <= n; i++) dp[i] = new Array(m + 1).fill(0); + for (var ii = n - 1; ii >= 0; ii--) { + for (var jj = m - 1; jj >= 0; jj--) { + if (a[ii] === b[jj]) dp[ii][jj] = dp[ii + 1][jj + 1] + 1; + else dp[ii][jj] = Math.max(dp[ii + 1][jj], dp[ii][jj + 1]); + } + } + + var ops = [], i = 0, j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { ops.push({ type: 'eq', text: a[i] }); i++; j++; } + else if (dp[i + 1][j] >= dp[i][j + 1]) { ops.push({ type: 'del', text: a[i] }); i++; } + else { ops.push({ type: 'add', text: b[j] }); j++; } + } + while (i < n) ops.push({ type: 'del', text: a[i++] }); + while (j < m) ops.push({ type: 'add', text: b[j++] }); + return ops; + } + + function diffLines(oldStr, newStr) { + var a = splitLines(oldStr), b = splitLines(newStr); + var ops = []; + + // Common prefix. + var start = 0; + while (start < a.length && start < b.length && a[start] === b[start]) start++; + // Common suffix (not overlapping the prefix). + var endA = a.length, endB = b.length; + while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; } + + for (var p = 0; p < start; p++) ops.push({ type: 'eq', text: a[p] }); + var mid = lcsDiff(a.slice(start, endA), b.slice(start, endB)); + for (var k = 0; k < mid.length; k++) ops.push(mid[k]); + for (var s = endA; s < a.length; s++) ops.push({ type: 'eq', text: a[s] }); + return ops; + } + + // Whitespace-preserving tokenization: words and the runs of + // whitespace between them are separate tokens, so a re-diff lines up + // on word boundaries while keeping the original spacing renderable. + function tokenize(s) { + return String(s == null ? '' : s).split(/(\s+)/).filter(function (x) { return x !== ''; }); + } + + function diffWords(oldStr, newStr) { + return lcsDiff(tokenize(oldStr), tokenize(newStr)); + } + + function stats(ops) { + var added = 0, removed = 0; + for (var i = 0; i < ops.length; i++) { + if (ops[i].type === 'add') added++; + else if (ops[i].type === 'del') removed++; + } + return { added: added, removed: removed }; + } + + if (!window.zddc) { + throw new Error('shared/diff.js: window.zddc must be loaded first'); + } + window.zddc.diff = { lines: diffLines, words: diffWords, stats: stats }; +})(); + // shared/zip-source.js — present the contents of a .zip as a tree of // File System Access API handles, so tools written against // FileSystemDirectoryHandle / FileSystemFileHandle (archive's scanner, @@ -6790,6 +7031,12 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr // whatever the server enforces on the // actual PUT/DELETE still apply. verbs: typeof e.verbs === 'string' ? e.verbs : undefined, + // Server-computed: true when this file lives in a history:true + // cascade subtree, so every save is versioned and + // GET ?history lists prior versions. Drives the "History…" + // context-menu affordance (server mode only — offline has no + // authenticated identity to attribute saves to). + history: !!e.history, // FS-API specific (null in server mode): handle: null }; @@ -11375,6 +11622,403 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr }; })(); +// history.js — markdown edit-history viewer for the browse tool. +// +// Surfaced by events.js as a "History…" right-click item on files in a +// history:true cascade subtree (working/). Server mode only — the audit +// trail (who saved when) is stamped server-side, so there's no offline +// equivalent. +// +// Talks to the zddc-server history endpoints on the file's own URL: +// GET ?history=1 → JSON [{ts, by, sha, prev, bytes, current}] +// GET ?history= → that version's raw bytes +// Restore re-PUTs a chosen version's bytes to , which the server +// records as a new version (forward-only; never destructive). +// +// Diffs are computed client-side via window.zddc.diff (shared/diff.js). + +(function () { + 'use strict'; + + function escapeHtml(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + } + + function toast(msg, kind) { + if (window.zddc && typeof window.zddc.toast === 'function') { + window.zddc.toast(msg, kind || 'info'); + } + } + + // Append ?history= (or &history=) to a file URL. + function histURL(baseURL, v) { + var sep = baseURL.indexOf('?') === -1 ? '?' : '&'; + return baseURL + sep + 'history=' + encodeURIComponent(v); + } + + function fmtTime(ts) { + var d = new Date(ts); + if (isNaN(d.getTime())) return ts || ''; + return d.toLocaleString(); + } + + function fmtBytes(n) { + if (n == null) return ''; + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + return (n / (1024 * 1024)).toFixed(1) + ' MB'; + } + + // Can the principal write (restore) to this file? Mirrors the + // events.js Rename/Delete gating: verbs===undefined means a non-zddc + // backend (no cascade signal) → allow the attempt; otherwise check w. + function canRestore(node) { + if (!node || !node.url) return false; + if (!window.zddc || !window.zddc.cap) return true; + if (typeof node.verbs !== 'string') return true; + return window.zddc.cap.has(node, 'w'); + } + + async function fetchList(node) { + var resp = await fetch(histURL(node.url, '1'), { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + var data = await resp.json(); + return Array.isArray(data) ? data : []; + } + + async function fetchVersion(node, sha) { + var resp = await fetch(histURL(node.url, sha), { credentials: 'same-origin' }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + return await resp.text(); + } + + // ── Modal shell ────────────────────────────────────────────────────── + // One overlay; its body is swapped between the list, a diff, and a + // single-version view. Returns { overlay, body, setTitle, close }. + function makeModal(titleText) { + var overlay = document.createElement('div'); + overlay.className = 'modal-overlay md-history-overlay'; + overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9000;'; + + var box = document.createElement('div'); + box.className = 'md-history-box'; + + var title = document.createElement('h2'); + title.className = 'md-history-title'; + title.textContent = titleText; + + var body = document.createElement('div'); + body.className = 'md-history-body'; + + box.appendChild(title); + box.appendChild(body); + overlay.appendChild(box); + document.body.appendChild(overlay); + + function close() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + document.removeEventListener('keydown', onKey); + } + function onKey(e) { if (e.key === 'Escape') close(); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('mousedown', function (e) { + if (e.target === overlay) close(); + }); + + return { + overlay: overlay, + body: body, + setTitle: function (t) { title.textContent = t; }, + close: close + }; + } + + function footerBar() { + var f = document.createElement('div'); + f.className = 'md-history-footer'; + return f; + } + + function button(label, opts) { + opts = opts || {}; + var b = document.createElement('button'); + b.type = 'button'; + b.textContent = label; + if (opts.primary) b.className = 'btn-primary'; + if (opts.disabled) b.disabled = true; + if (opts.onClick) b.addEventListener('click', opts.onClick); + return b; + } + + // ── List view ────────────────────────────────────────────────────── + function renderList(modal, node, entries) { + modal.setTitle('History — ' + node.name); + var body = modal.body; + body.innerHTML = ''; + + if (!entries.length) { + var empty = document.createElement('p'); + empty.className = 'md-history-empty'; + empty.textContent = 'No saved versions yet. Each save of this file is recorded here.'; + body.appendChild(empty); + var f0 = footerBar(); + f0.appendChild(button('Close', { onClick: modal.close })); + body.appendChild(f0); + return; + } + + var hint = document.createElement('p'); + hint.className = 'md-history-hint'; + hint.textContent = 'Newest first. Select two versions to diff.'; + body.appendChild(hint); + + var list = document.createElement('div'); + list.className = 'md-history-list'; + var selected = []; // shas, in click order (max 2) + + var diffBtn; + function syncDiffBtn() { + if (diffBtn) diffBtn.disabled = selected.length !== 2; + } + + entries.forEach(function (ent) { + var row = document.createElement('div'); + row.className = 'md-history-row' + (ent.current ? ' is-current' : ''); + + var cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.className = 'md-history-pick'; + cb.addEventListener('change', function () { + if (cb.checked) { + selected.push(ent.sha); + // Keep at most two: drop the oldest selection. + if (selected.length > 2) { + var dropped = selected.shift(); + var others = list.querySelectorAll('.md-history-pick'); + others.forEach(function (o, i) { + if (o !== cb && entries[i] && entries[i].sha === dropped) o.checked = false; + }); + } + } else { + selected = selected.filter(function (s) { return s !== ent.sha; }); + } + syncDiffBtn(); + }); + + var meta = document.createElement('div'); + meta.className = 'md-history-meta'; + meta.innerHTML = + '' + escapeHtml(fmtTime(ent.ts)) + '' + + '' + escapeHtml(ent.by || '—') + '' + + '' + escapeHtml(fmtBytes(ent.bytes)) + '' + + (ent.current ? 'current' : ''); + + var actions = document.createElement('div'); + actions.className = 'md-history-actions'; + actions.appendChild(button('View', { + onClick: function () { renderView(modal, node, ent, entries); } + })); + if (!ent.current && canRestore(node)) { + actions.appendChild(button('Restore', { + onClick: function () { restore(modal, node, ent); } + })); + } + + row.appendChild(cb); + row.appendChild(meta); + row.appendChild(actions); + list.appendChild(row); + }); + + body.appendChild(list); + + var f = footerBar(); + diffBtn = button('Diff selected', { + primary: true, disabled: true, + onClick: function () { + if (selected.length !== 2) return; + // Order oldest→newest by the entries' position (newest + // first in the list), so the diff reads old → new. + var picks = entries.filter(function (e) { return selected.indexOf(e.sha) !== -1; }); + picks.sort(function (a, b) { return (a.ts < b.ts ? -1 : 1); }); + renderDiff(modal, node, picks[0], picks[1], entries); + } + }); + f.appendChild(diffBtn); + f.appendChild(button('Close', { onClick: modal.close })); + body.appendChild(f); + } + + // ── Single-version view ────────────────────────────────────────────── + async function renderView(modal, node, ent, entries) { + modal.setTitle('Version — ' + fmtTime(ent.ts)); + var body = modal.body; + body.innerHTML = '

Loading…

'; + var text; + try { + text = await fetchVersion(node, ent.sha); + } catch (e) { + body.innerHTML = ''; + var err = document.createElement('p'); + err.className = 'md-history-empty'; + err.textContent = 'Could not load this version: ' + (e.message || e); + body.appendChild(err); + return; + } + body.innerHTML = ''; + var meta = document.createElement('p'); + meta.className = 'md-history-hint'; + meta.textContent = (ent.by || '—') + ' · ' + fmtTime(ent.ts); + body.appendChild(meta); + + var pre = document.createElement('pre'); + pre.className = 'md-history-pre'; + pre.textContent = text; + body.appendChild(pre); + + var f = footerBar(); + f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } })); + if (!ent.current && canRestore(node)) { + f.appendChild(button('Restore this version', { + primary: true, onClick: function () { restore(modal, node, ent); } + })); + } + body.appendChild(f); + } + + // ── Diff view ───────────────────────────────────────────────────────── + async function renderDiff(modal, node, oldEnt, newEnt, entries) { + modal.setTitle('Diff'); + var body = modal.body; + body.innerHTML = '

Loading…

'; + var oldText, newText; + try { + oldText = await fetchVersion(node, oldEnt.sha); + newText = await fetchVersion(node, newEnt.sha); + } catch (e) { + body.innerHTML = ''; + var err = document.createElement('p'); + err.className = 'md-history-empty'; + err.textContent = 'Could not load versions: ' + (e.message || e); + body.appendChild(err); + return; + } + body.innerHTML = ''; + + var hdr = document.createElement('p'); + hdr.className = 'md-history-hint'; + hdr.innerHTML = + '' + escapeHtml(fmtTime(oldEnt.ts)) + ' · ' + escapeHtml(oldEnt.by || '—') + '' + + ' → ' + + '' + escapeHtml(fmtTime(newEnt.ts)) + ' · ' + escapeHtml(newEnt.by || '—') + ''; + body.appendChild(hdr); + + var ops = (window.zddc && window.zddc.diff) + ? window.zddc.diff.lines(oldText, newText) + : null; + + var pane = document.createElement('div'); + pane.className = 'md-diff'; + if (!ops) { + pane.textContent = 'Diff unavailable (diff module not loaded).'; + } else { + var unchanged = true; + ops.forEach(function (op) { + if (op.type !== 'eq') unchanged = false; + var line = document.createElement('div'); + line.className = 'md-diff-line md-diff-' + op.type; + var gutter = op.type === 'add' ? '+' : (op.type === 'del' ? '-' : ' '); + var g = document.createElement('span'); + g.className = 'md-diff-gutter'; + g.textContent = gutter; + var t = document.createElement('span'); + t.className = 'md-diff-text'; + t.textContent = op.text; + line.appendChild(g); + line.appendChild(t); + pane.appendChild(line); + }); + if (unchanged) { + var same = document.createElement('div'); + same.className = 'md-diff-line md-diff-eq'; + same.textContent = '(no differences)'; + pane.appendChild(same); + } + } + body.appendChild(pane); + + if (window.zddc && window.zddc.diff && ops) { + var s = window.zddc.diff.stats(ops); + var statline = document.createElement('p'); + statline.className = 'md-history-hint'; + statline.textContent = '+' + s.added + ' / −' + s.removed; + body.appendChild(statline); + } + + var f = footerBar(); + f.appendChild(button('Back', { onClick: function () { renderList(modal, node, entries); } })); + body.appendChild(f); + } + + // ── Restore ─────────────────────────────────────────────────────────── + async function restore(modal, node, ent) { + if (!confirm('Restore the version from ' + fmtTime(ent.ts) + '?\nThis is saved as a new version — nothing is lost.')) { + return; + } + try { + var text = await fetchVersion(node, ent.sha); + var resp = await fetch(node.url, { + method: 'PUT', + credentials: 'same-origin', + headers: { 'Content-Type': 'text/markdown' }, + body: text + }); + if (!resp.ok) throw new Error('HTTP ' + resp.status); + toast('Restored version from ' + fmtTime(ent.ts), 'success'); + // Reflect the new head: refetch the list. + var entries = await fetchList(node); + renderList(modal, node, entries); + // If the file is open in the preview pane, reload it. + var preview = window.app && window.app.modules && window.app.modules.preview; + if (preview && typeof preview.showFilePreview === 'function') { + try { preview.showFilePreview(node); } catch (_e) { /* best effort */ } + } + } catch (e) { + toast('Restore failed: ' + (e.message || e), 'error'); + } + } + + // ── Entry point ───────────────────────────────────────────────────── + async function open(node) { + if (!node || !node.url) { + toast('History is only available in server mode.', 'error'); + return; + } + var modal = makeModal('History — ' + node.name); + modal.body.innerHTML = '

Loading…

'; + try { + var entries = await fetchList(node); + renderList(modal, node, entries); + } catch (e) { + modal.body.innerHTML = ''; + var err = document.createElement('p'); + err.className = 'md-history-empty'; + err.textContent = 'Could not load history: ' + (e.message || e); + modal.body.appendChild(err); + var f = footerBar(); + f.appendChild(button('Close', { onClick: modal.close })); + modal.body.appendChild(f); + } + } + + window.app.modules.history = { open: open }; +})(); + // create-transmittal.js — folder-creation plumbing for outgoing // transmittals. // @@ -12585,6 +13229,24 @@ var e=function(t,n){return e=Object.setPrototypeOf||{__proto__:[]}instanceof Arr if (s) s.invokeUnstage(c.node); } }, + // ── Version history (history:true subtree, real files only) ── + // Server-mode only: the audit trail (who saved when) is + // server-stamped, so there's no offline equivalent. node.history + // is set by the listing when this file sits in a history-enabled + // cascade subtree (working/). + { + label: 'History…', + icon: '🕘', + visible: function (c) { + if (!serverMode) return false; + if (c.node.isDir || c.node.isZip || c.node.virtual) return false; + return !!c.node.history; + }, + action: function (c) { + var h = window.app.modules.history; + if (h) h.open(c.node); + } + }, { separator: true }, // ── View ── diff --git a/zddc/internal/apps/embedded/classifier.html b/zddc/internal/apps/embedded/classifier.html index 4d93f58..862a5f6 100644 --- a/zddc/internal/apps/embedded/classifier.html +++ b/zddc/internal/apps/embedded/classifier.html @@ -1793,7 +1793,7 @@ body.is-elevated::after {
ZDDC Classifier - v0.0.24 + v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
diff --git a/zddc/internal/apps/embedded/index.html b/zddc/internal/apps/embedded/index.html index 415b012..3c7fa8b 100644 --- a/zddc/internal/apps/embedded/index.html +++ b/zddc/internal/apps/embedded/index.html @@ -1536,7 +1536,7 @@ body {
ZDDC - v0.0.24 + v0.0.25-beta · 2026-05-28 19:19:54 · 9972e67
diff --git a/zddc/internal/apps/embedded/transmittal.html b/zddc/internal/apps/embedded/transmittal.html index dddf42a..0169d90 100644 --- a/zddc/internal/apps/embedded/transmittal.html +++ b/zddc/internal/apps/embedded/transmittal.html @@ -2635,7 +2635,7 @@ dialog.modal--narrow {
ZDDC Transmittal - v0.0.24 + v0.0.25-beta · 2026-05-28 19:19:53 · 9972e67
JavaScript not available