From 5363b5364c1f59d2eef264903f3c9471f02a6f77 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Thu, 7 May 2026 08:19:18 -0500 Subject: [PATCH] feat(zddc): add ParseTransmittalFolder + IsTrnOrSubTracking helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts the YYYY-MM-DD_ () - grammar into a reusable parser in the zddc package, and exposes a tracking-type predicate for -TRN- / -SUB- (case-fold). The transmittal-folder regex was previously only inside archive/index.go where it captured just the date; the new ParseTransmittalFolder also returns tracking, status, and title so handlers can recognise transmittal envelopes for upcoming staging↔working mirror logic. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- zddc/internal/zddc/folder.go | 48 +++++++++++++++++ zddc/internal/zddc/folder_test.go | 89 +++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 zddc/internal/zddc/folder.go create mode 100644 zddc/internal/zddc/folder_test.go diff --git a/zddc/internal/zddc/folder.go b/zddc/internal/zddc/folder.go new file mode 100644 index 0000000..c9c28b6 --- /dev/null +++ b/zddc/internal/zddc/folder.go @@ -0,0 +1,48 @@ +package zddc + +import ( + "regexp" + "strings" +) + +// transmittalFolderRE matches the canonical ZDDC transmittal-folder shape: +// +// YYYY-MM-DD_<tracking> (<status>) - <title> +// +// where <tracking> has no spaces or underscores, <status> is anything inside +// parentheses, and <title> is anything after the dash. Capture groups: +// +// 1: date (YYYY-MM-DD) +// 2: tracking number (e.g. proj-EM-TRN-0042) +// 3: status (e.g. IFR, IFA, RSA, DFT) +// 4: title +var transmittalFolderRE = regexp.MustCompile( + `^(\d{4}-\d{2}-\d{2})_([^_\s]+(?:-[^_\s]+)*)\s*\(([^)]+)\)\s*-\s*(.+)$`, +) + +// ParseTransmittalFolder splits a folder basename into its ZDDC transmittal +// components. The fourth return is true iff name is a well-formed transmittal +// folder. Trailing slashes on name are tolerated. +// +// Used by handlers that need to recognise a staging-folder mkdir as a +// transmittal envelope (to mirror it under working/), and by the archive +// indexer that scans for transmittal folders on disk. +func ParseTransmittalFolder(name string) (date, tracking, status, title string, ok bool) { + name = strings.TrimRight(name, "/") + m := transmittalFolderRE.FindStringSubmatch(name) + if m == nil { + return "", "", "", "", false + } + return m[1], m[2], m[3], m[4], true +} + +// IsTrnOrSubTracking reports whether tracking contains a "-TRN-" or "-SUB-" +// segment (case-insensitive). These two tracking types are the ones whose +// staging folders get a paired drafting folder under working/. +// +// "-MDL-" and other tracking types do NOT match — MDL deliverables are +// tracked via per-party mdl/ rows, not via the working↔staging mirror. +func IsTrnOrSubTracking(tracking string) bool { + upper := strings.ToUpper(tracking) + return strings.Contains(upper, "-TRN-") || strings.Contains(upper, "-SUB-") +} diff --git a/zddc/internal/zddc/folder_test.go b/zddc/internal/zddc/folder_test.go new file mode 100644 index 0000000..7caad5d --- /dev/null +++ b/zddc/internal/zddc/folder_test.go @@ -0,0 +1,89 @@ +package zddc + +import "testing" + +func TestParseTransmittalFolder(t *testing.T) { + type expect struct { + date, tracking, status, title string + ok bool + } + cases := map[string]expect{ + "2026-06-15_proj-EM-TRN-0042 (DFT) - Foundation Plans": { + date: "2026-06-15", tracking: "proj-EM-TRN-0042", status: "DFT", title: "Foundation Plans", ok: true, + }, + "2026-01-15_123456-EM-SUB-0001 (IFR) - Submittal Title.with.dots": { + date: "2026-01-15", tracking: "123456-EM-SUB-0001", status: "IFR", title: "Submittal Title.with.dots", ok: true, + }, + "2026-01-15_proj-EM-SUB-0001 (RSA) - Response Notes": { + date: "2026-01-15", tracking: "proj-EM-SUB-0001", status: "RSA", title: "Response Notes", ok: true, + }, + // Whitespace variations around "(" and "-". + "2026-01-15_proj-EM-TRN-1(IFC)-Title": { + date: "2026-01-15", tracking: "proj-EM-TRN-1", status: "IFC", title: "Title", ok: true, + }, + // MDL is a valid transmittal name even though it doesn't match TRN/SUB. + "2026-01-15_proj-EM-MDL-0001 (IFR) - Master Deliverables List": { + date: "2026-01-15", tracking: "proj-EM-MDL-0001", status: "IFR", title: "Master Deliverables List", ok: true, + }, + // Negatives. + "": {ok: false}, + "scratch": {ok: false}, + "2026-06-15 missing-underscore": {ok: false}, + "NotADate_proj-EM-TRN-0042 (DFT) - Title": {ok: false}, + "2026-06-15_no-status - Title": {ok: false}, + "2026-06-15_no-dash (IFC) Title": {ok: false}, + // Tracking with whitespace must not parse — tracking has no spaces. + "2026-06-15_proj EM TRN (IFC) - Title": {ok: false}, + } + for in, want := range cases { + date, tracking, status, title, ok := ParseTransmittalFolder(in) + if ok != want.ok { + t.Errorf("ParseTransmittalFolder(%q) ok=%v, want %v", in, ok, want.ok) + continue + } + if !ok { + continue + } + if date != want.date || tracking != want.tracking || status != want.status || title != want.title { + t.Errorf("ParseTransmittalFolder(%q) = (%q,%q,%q,%q), want (%q,%q,%q,%q)", + in, date, tracking, status, title, want.date, want.tracking, want.status, want.title) + } + } +} + +func TestParseTransmittalFolderTrailingSlash(t *testing.T) { + date, tracking, _, _, ok := ParseTransmittalFolder("2026-06-15_proj-TRN-1 (DFT) - x/") + if !ok || date != "2026-06-15" || tracking != "proj-TRN-1" { + t.Errorf("trailing slash not tolerated: ok=%v date=%q tracking=%q", ok, date, tracking) + } +} + +func TestIsTrnOrSubTracking(t *testing.T) { + yes := []string{ + "proj-EM-TRN-0042", + "proj-EM-SUB-0001", + "PROJ-em-trn-0001", // case-fold + "vendor-sub-9", + "a-TRN-b-SUB-c", // either qualifies + } + no := []string{ + "", + "proj-EM-MDL-0001", + "trn-without-dashes", + "sub-without-dashes", + "prefixTRN", // no dashes around TRN + "-TRN", // missing trailing dash + "TRN-", // missing leading dash + "proj-TRNX-001", // not exactly -TRN- + } + for _, s := range yes { + if !IsTrnOrSubTracking(s) { + t.Errorf("IsTrnOrSubTracking(%q) = false, want true", s) + } + } + for _, s := range no { + if IsTrnOrSubTracking(s) { + t.Errorf("IsTrnOrSubTracking(%q) = true, want false", s) + } + } +}