package archive import ( "os" "path/filepath" "regexp" "sort" "strings" "sync" ) // RevisionEntry holds the resolved file paths for one base revision. type RevisionEntry struct { BasePath string // server-relative path for trackingNumber_rev.html Modifiers map[string]string // modifier key (e.g. "C1") → server-relative path Date string // transmittal date (YYYY-MM-DD) for first-seen logic } // TrackingEntry holds all revision data for one tracking number. type TrackingEntry struct { HighestBaseRev string // highest base revision (for trackingNumber.html) ByRevision map[string]*RevisionEntry // base revision → entry } // Index is the in-memory archive index. type Index struct { mu sync.RWMutex ByTracking map[string]*TrackingEntry } // NewIndex returns an empty Index. func NewIndex() *Index { return &Index{ ByTracking: make(map[string]*TrackingEntry), } } // transmittalFolderRE matches: YYYY-MM-DD_anything (anything) - anything var transmittalFolderRE = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2})_[^_\s]+\s*\([^)]+\)\s*-\s*.+$`) // zddc filename: trackingNumber_revision (status) - title.ext // trackingNumber: no spaces or underscores // revision: ~?[A-Z0-9]+(+[CBNQ][0-9]+)? var zddcFilenameRE = regexp.MustCompile( `^([^_\s]+(?:-[^_\s]+)*)_(~?[A-Z0-9]+)(\+[CBNQ][0-9]+)?\s+\([^)]+\)\s*-\s*.+\.([^.]+)$`, ) type parsedFile struct { trackingNumber string baseRev string modifier string // empty or e.g. "C1" date string // transmittal folder date serverPath string // server-relative path (slash-separated, no leading slash) } // BuildIndex walks fsRoot, finds all transmittal folders, and builds the index. func BuildIndex(fsRoot string) (*Index, error) { idx := NewIndex() if err := walkAndIndex(idx, fsRoot, fsRoot, ""); err != nil { return nil, err } return idx, nil } // walkAndIndex recursively walks dirAbs looking for transmittal folders. // serverDir is the server-relative path of dirAbs (slash-separated, no leading slash). func walkAndIndex(idx *Index, fsRoot, dirAbs, serverDir string) error { entries, err := os.ReadDir(dirAbs) if err != nil { return err } for _, entry := range entries { name := entry.Name() if strings.HasPrefix(name, ".") { continue } if !entry.IsDir() { continue } var childServerDir string if serverDir == "" { childServerDir = name } else { childServerDir = serverDir + "/" + name } childAbs := filepath.Join(dirAbs, name) if m := transmittalFolderRE.FindStringSubmatch(name); m != nil { // This is a transmittal folder — index its files date := m[1] if err := indexTransmittalFolder(idx, fsRoot, childAbs, childServerDir, date); err != nil { // Non-fatal: log and continue continue } } else { // Recurse into grouping/portfolio/project folders if err := walkAndIndex(idx, fsRoot, childAbs, childServerDir); err != nil { continue } } } return nil } // indexTransmittalFolder indexes all ZDDC files in a transmittal folder. func indexTransmittalFolder(idx *Index, fsRoot, folderAbs, folderServerPath, date string) error { return filepath.WalkDir(folderAbs, func(path string, d os.DirEntry, err error) error { if err != nil { // Log the error but continue indexing other files _ = err // would log here: slog.Warn("walkdir error", "path", path, "err", err) return nil } if d.IsDir() { return nil } name := d.Name() if strings.HasPrefix(name, ".") { return nil } m := zddcFilenameRE.FindStringSubmatch(name) if m == nil { return nil } tracking := m[1] baseRev := m[2] modifierFull := m[3] // e.g. "+C1" or "" modifier := "" if modifierFull != "" { modifier = modifierFull[1:] // strip leading "+" } // Build server-relative path relPath, err := filepath.Rel(fsRoot, path) if err != nil { return nil } serverPath := filepath.ToSlash(relPath) pf := parsedFile{ trackingNumber: tracking, baseRev: baseRev, modifier: modifier, date: date, serverPath: serverPath, } idx.recordFile(pf) return nil }) } // recordFile adds a parsed file to the index using first-seen (oldest date) logic. func (idx *Index) recordFile(pf parsedFile) { idx.mu.Lock() defer idx.mu.Unlock() te, ok := idx.ByTracking[pf.trackingNumber] if !ok { te = &TrackingEntry{ ByRevision: make(map[string]*RevisionEntry), } idx.ByTracking[pf.trackingNumber] = te } re, ok := te.ByRevision[pf.baseRev] if !ok { re = &RevisionEntry{ Modifiers: make(map[string]string), } te.ByRevision[pf.baseRev] = re } if pf.modifier == "" { // Base revision file — record if this transmittal is older than current if re.BasePath == "" || pf.date < re.Date { re.BasePath = pf.serverPath re.Date = pf.date } } else { // Modifier file — record if no entry yet or this transmittal is older if existing, exists := re.Modifiers[pf.modifier]; !exists || pf.date < re.Date { _ = existing re.Modifiers[pf.modifier] = pf.serverPath } } // Update highest base revision te.HighestBaseRev = highestRevision(te) } // highestRevision returns the highest base revision among all revisions in te. // Revision ordering: numeric revisions (0,1,2…) are lower than alphabetic (A,B,C…). // Draft prefix ~ means lower than base. func highestRevision(te *TrackingEntry) string { if len(te.ByRevision) == 0 { return "" } revs := make([]string, 0, len(te.ByRevision)) for r := range te.ByRevision { revs = append(revs, r) } sort.Slice(revs, func(i, j int) bool { return compareRevisions(revs[i], revs[j]) < 0 }) return revs[len(revs)-1] } // compareRevisions returns negative if a < b, 0 if equal, positive if a > b. // Order: ~rev < numeric < alpha (A < B < C ...) func compareRevisions(a, b string) int { isDraftA := strings.HasPrefix(a, "~") isDraftB := strings.HasPrefix(b, "~") baseA := strings.TrimPrefix(a, "~") baseB := strings.TrimPrefix(b, "~") // Draft < non-draft of same base if baseA == baseB { if isDraftA && !isDraftB { return -1 } if !isDraftA && isDraftB { return 1 } return 0 } // Numeric vs alpha: numeric comes first aIsNum := len(baseA) > 0 && baseA[0] >= '0' && baseA[0] <= '9' bIsNum := len(baseB) > 0 && baseB[0] >= '0' && baseB[0] <= '9' if aIsNum && !bIsNum { return -1 } if !aIsNum && bIsNum { return 1 } // Both numeric or both alpha: string comparison (works for single-char alpha) if baseA < baseB { return -1 } if baseA > baseB { return 1 } return 0 } // UpdateFromDir re-indexes a single transmittal folder (called by the watcher). func (idx *Index) UpdateFromDir(fsRoot, transmittalDirPath string) error { // Determine the date from the folder name folderName := filepath.Base(transmittalDirPath) m := transmittalFolderRE.FindStringSubmatch(folderName) if m == nil { return nil // not a transmittal folder } date := m[1] // Compute server-relative path for this folder rel, err := filepath.Rel(fsRoot, transmittalDirPath) if err != nil { return err } serverDir := filepath.ToSlash(rel) return indexTransmittalFolder(idx, fsRoot, transmittalDirPath, serverDir, date) } // TrackingEntrySummary is a minimal summary for archive directory listings. type TrackingEntrySummary struct { TrackingNumber string HighestPath string // server-relative path for the highest base revision } // AllTrackingEntries returns a snapshot of all tracking entries. // Safe for concurrent use. func (idx *Index) AllTrackingEntries() []TrackingEntrySummary { idx.mu.RLock() defer idx.mu.RUnlock() result := make([]TrackingEntrySummary, 0, len(idx.ByTracking)) for tn, te := range idx.ByTracking { var highPath string if te.HighestBaseRev != "" { if re, ok := te.ByRevision[te.HighestBaseRev]; ok { highPath = re.BasePath } } result = append(result, TrackingEntrySummary{ TrackingNumber: tn, HighestPath: highPath, }) } return result }