Four entangled change-sets from one session, committed together because
their file-level overlap (build.sh, docs, embedded/, watcher.go, …) makes
post-hoc separation noisy:
* fix(archive): nested-party + folder-type cascade
transmittalIsUnderVisibleParty short-circuited on the first matched
party segment, only checking the immediately-next segment for a
folder-type marker. Paths like BM/sub/Issued/<txn> bypassed the Issued
toggle entirely. Replaced with isUnderHiddenFolderType (full-path) +
any-segment party match. Eight new Playwright cases pin the contract
in tests/archive-cascade.spec.js.
* refactor(zddc-server): scope .archive index by project
archive.Index now buckets by top-level segment
(.ByProject[<project>].ByTracking[<tracking>]). Resolve and AllEntries
take a project parameter; handler extracts it from contextPath's first
segment. /.archive/ at root returns 404 — stable refs must be
project-rooted. Within-project (tracking, rev) collisions emit a WARN
with both paths. Cross-project tracking-number duplicates no longer
collide.
* perf(zddc-server): lazy-load expensive bits of the profile page
serveProfilePage now ships a minimal shell: Email, EmailHeader,
IsSuperAdmin (root .zddc only). Visible projects + admin subtrees +
editable scaffolds populate client-side via /.profile/access. Subtree-
admin scaffolds live in <template id="tmpl-subtree-admin">; pure
non-admins receive no live admin form. ScanZddcFiles now memoized,
invalidated on .zddc events by the watcher and writer helpers.
* feat: lockstep release + redesigned releases page
sh build.sh --release [version|alpha|beta] is the canonical lockstep
cut: every tool (5 HTML + zddc-server) bumps to the same coordinated
version. zddc-server binaries now committed under website/releases/
with the same cascade chain as HTML tools (no more Codeberg release-
asset publication). zddc/release.sh deprecated (kept as a guard);
shared/publish-codeberg-release.sh removed.
Releases page redesigned as an action-first install guide: hero +
version dropdown that rewires every download link, channel chips for
always-visible alpha/beta access (state-aware labels: "tracks stable"
vs "active dev"), Path A (zddc-server with platform auto-detect from
UA), Path B (5 standalone tool HTMLs), version-pinning empowerment
narrative (drop-a-copy vs .zddc apps: cascade), channels explainer.
Channel-link verifier asserts every <tool>_{stable,beta,alpha}.html
resolves at the end of every build. Bootstrap-friendly: zddc-server
artifact checks skip until the first lockstep cut anchors the chain.
Tests: 167 Playwright + all Go packages green.
Docs: CLAUDE.md, AGENTS.md, ARCHITECTURE.md, zddc/README.md updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
3 KiB
Go
136 lines
3 KiB
Go
package archive
|
|
|
|
import (
|
|
"context"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
|
|
"github.com/fsnotify/fsnotify"
|
|
)
|
|
|
|
// Watcher watches fsRoot for filesystem changes and updates the archive index
|
|
// and the zddc ACL policy cache.
|
|
type Watcher struct {
|
|
fsRoot string
|
|
idx *Index
|
|
watcher *fsnotify.Watcher
|
|
|
|
// Debounce: pending dir → timer
|
|
mu sync.Mutex
|
|
pending map[string]*time.Timer
|
|
}
|
|
|
|
// NewWatcher creates a new Watcher. Call Start to begin watching.
|
|
func NewWatcher(fsRoot string, idx *Index) (*Watcher, error) {
|
|
w, err := fsnotify.NewWatcher()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Watcher{
|
|
fsRoot: fsRoot,
|
|
idx: idx,
|
|
watcher: w,
|
|
pending: make(map[string]*time.Timer),
|
|
}, nil
|
|
}
|
|
|
|
// Start begins watching and blocks until ctx is cancelled.
|
|
// It registers the entire directory tree under fsRoot.
|
|
func (w *Watcher) Start(ctx context.Context) error {
|
|
// Walk and register all directories
|
|
if err := filepath.WalkDir(w.fsRoot, func(path string, d os.DirEntry, err error) error {
|
|
if err != nil || !d.IsDir() {
|
|
return nil
|
|
}
|
|
return w.watcher.Add(path)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
go func() {
|
|
defer w.watcher.Close()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
|
|
case event, ok := <-w.watcher.Events:
|
|
if !ok {
|
|
return
|
|
}
|
|
w.handleEvent(event)
|
|
|
|
case err, ok := <-w.watcher.Errors:
|
|
if !ok {
|
|
return
|
|
}
|
|
slog.Warn("fsnotify error", "err", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
<-ctx.Done()
|
|
return nil
|
|
}
|
|
|
|
func (w *Watcher) handleEvent(event fsnotify.Event) {
|
|
path := event.Name
|
|
base := filepath.Base(path)
|
|
|
|
// New directory created — register it
|
|
if event.Has(fsnotify.Create) {
|
|
if info, err := os.Stat(path); err == nil && info.IsDir() {
|
|
_ = w.watcher.Add(path)
|
|
}
|
|
}
|
|
|
|
// .zddc file changed — invalidate ACL policy cache for the chain rooted
|
|
// here AND the global scan cache (which lists every directory containing
|
|
// a .zddc file). The scan cache is fsRoot-keyed and only changes when
|
|
// .zddc files are added/removed, but distinguishing modify from
|
|
// create/remove in fsnotify is fragile so we just always invalidate.
|
|
if base == ".zddc" {
|
|
dir := filepath.Dir(path)
|
|
zddc.InvalidateCache(dir)
|
|
zddc.InvalidateScanCache()
|
|
return
|
|
}
|
|
|
|
// Skip dot-files
|
|
if strings.HasPrefix(base, ".") {
|
|
return
|
|
}
|
|
|
|
// For transmittal folder events, schedule a debounced index update
|
|
dirPath := filepath.Dir(path)
|
|
dirName := filepath.Base(dirPath)
|
|
if transmittalFolderRE.MatchString(dirName) {
|
|
w.scheduleIndexUpdate(dirPath)
|
|
}
|
|
}
|
|
|
|
// scheduleIndexUpdate debounces an index update for dirPath (2-second delay).
|
|
func (w *Watcher) scheduleIndexUpdate(dirPath string) {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
|
|
if t, ok := w.pending[dirPath]; ok {
|
|
t.Reset(2 * time.Second)
|
|
return
|
|
}
|
|
|
|
w.pending[dirPath] = time.AfterFunc(2*time.Second, func() {
|
|
w.mu.Lock()
|
|
delete(w.pending, dirPath)
|
|
w.mu.Unlock()
|
|
|
|
if err := w.idx.UpdateFromDir(w.fsRoot, dirPath); err != nil {
|
|
slog.Warn("archive index update failed", "dir", dirPath, "err", err)
|
|
}
|
|
})
|
|
}
|