From 275391fba0292d2a581d566e13d0f7aa3f2c6988 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 22:12:03 -0400 Subject: [PATCH 1/5] feat(catalog): parse CATALOG.031 + extract per-cell chart metadata MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation for the chart-library work (parse CATALOG files; show per-upload chart info). Two self-contained, tested pieces: - pkg/s57: ParseCatalog/ParseCatalogFS decode an ENC exchange-set CATALOG.031 (ISO 8211 CATD records) into per-file entries — long name (LFIL), IMPL, coverage bbox (SLAT/WLON/NLAT/ELON), CRC — with helpers for base-cell filtering and the union bbox. Decoded positionally from the raw CATD field (NOAA/IHO ASCII layout) since the catalogue carries no DDR subfield labels. Tested against a real NOAA fixture. - internal/engine/baker: ExtractCellMeta gathers per-cell metadata (compilation scale, edition/update/issue date, producing agency, coverage bbox) via the same cheap coverage-only parse the bake's pass 1 uses, so it adds no full re-parse. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/baker/meta.go | 85 +++++++++++ internal/engine/baker/meta_test.go | 42 ++++++ pkg/s57/catalog.go | 210 ++++++++++++++++++++++++++ pkg/s57/catalog_test.go | 89 +++++++++++ pkg/s57/testdata/US5MD1MC_CATALOG.031 | 1 + 5 files changed, 427 insertions(+) create mode 100644 internal/engine/baker/meta.go create mode 100644 internal/engine/baker/meta_test.go create mode 100644 pkg/s57/catalog.go create mode 100644 pkg/s57/catalog_test.go create mode 100644 pkg/s57/testdata/US5MD1MC_CATALOG.031 diff --git a/internal/engine/baker/meta.go b/internal/engine/baker/meta.go new file mode 100644 index 0000000..a07bb83 --- /dev/null +++ b/internal/engine/baker/meta.go @@ -0,0 +1,85 @@ +package baker + +import ( + "sort" + "strconv" +) + +// CellMeta is the per-cell metadata extracted at import time for the chart +// library to display. It comes from the cell's S-57 header (DSID/DSPM) plus its +// M_COVR coverage — gathered with the same cheap coverage-only parse the bake's +// pass 1 uses, so it adds no full re-parse. Title is left to the caller to fill +// from the exchange-set catalogue (CATALOG.031 LFIL), since S-57 headers carry +// no human chart name. +type CellMeta struct { + Name string `json:"name"` // cell stem, e.g. "US5MD1MC" + Title string `json:"title,omitempty"` // long name (from CATALOG.031), else dataset name + Scale int `json:"scale,omitempty"` // compilation scale denominator (CSCL) + Edition string `json:"edition,omitempty"` + Update string `json:"update,omitempty"` + IssueDate string `json:"issueDate,omitempty"` // YYYYMMDD + Agency int `json:"agency,omitempty"` // IHO producing-agency code (550 = NOAA) + BBox [4]float64 `json:"bbox,omitempty"` // [west, south, east, north] + HasBBox bool `json:"-"` +} + +// ExtractCellMeta parses each cell's header + coverage (coverage-only, cheap) and +// returns per-cell metadata keyed by cell stem. Cells that fail to parse are +// reported via onSkip and omitted. Title is populated with the dataset name as a +// fallback; the caller overlays the catalogue long name where available. +func ExtractCellMeta(cells map[string]CellData, onSkip func(name string, err error)) map[string]CellMeta { + out := make(map[string]CellMeta, len(cells)) + names := make([]string, 0, len(cells)) + for n := range cells { + names = append(names, n) + } + sort.Strings(names) + for _, name := range names { + cd := cells[name] + chart, err := ParseCellCoverage(name, cd.Base, cd.Updates) + if err != nil { + if onSkip != nil { + onSkip(name, err) + } + continue + } + stem := cellStem(chart.DatasetName()) + if stem == "" { + stem = cellStem(name) + } + m := CellMeta{ + Name: stem, + Title: chart.DatasetName(), + Scale: int(chart.CompilationScale()), + Edition: chart.Edition(), + Update: chart.UpdateNumber(), + IssueDate: chart.IssueDate(), + Agency: chart.ProducingAgency(), + } + b := chart.Bounds() + if b.MaxLon > b.MinLon && b.MaxLat > b.MinLat { + m.BBox = [4]float64{b.MinLon, b.MinLat, b.MaxLon, b.MaxLat} + m.HasBBox = true + } + out[stem] = m + } + return out +} + +// cellStem trims a trailing ".000"/".NNN" or directory path from a cell name. +func cellStem(name string) string { + // Strip any directory. + for i := len(name) - 1; i >= 0; i-- { + if name[i] == '/' || name[i] == '\\' { + name = name[i+1:] + break + } + } + // Strip a 3-digit S-57 extension. + if n := len(name); n >= 4 && name[n-4] == '.' { + if _, err := strconv.Atoi(name[n-3:]); err == nil { + return name[:n-4] + } + } + return name +} diff --git a/internal/engine/baker/meta_test.go b/internal/engine/baker/meta_test.go new file mode 100644 index 0000000..83c2a67 --- /dev/null +++ b/internal/engine/baker/meta_test.go @@ -0,0 +1,42 @@ +package baker + +import ( + "os" + "testing" +) + +func TestExtractCellMeta(t *testing.T) { + data, err := os.ReadFile("../../../testdata/US5MD1MC.000") + if err != nil { + t.Fatal(err) + } + meta := ExtractCellMeta(map[string]CellData{"US5MD1MC.000": {Base: data}}, nil) + m, ok := meta["US5MD1MC"] + if !ok { + t.Fatalf("no metadata for US5MD1MC; got keys %v", keys(meta)) + } + if m.Scale != 12000 { + t.Errorf("Scale = %d, want 12000", m.Scale) + } + if m.Agency != 550 { // 550 = NOAA (US) + t.Errorf("Agency = %d, want 550 (NOAA)", m.Agency) + } + if m.IssueDate == "" { + t.Error("expected an issue date") + } + if !m.HasBBox { + t.Error("expected a coverage bbox") + } + // Annapolis Harbor is around 38.9–39.0 N, -76.5–-76.4 W. + if m.BBox[0] < -77 || m.BBox[0] > -76 || m.BBox[3] < 38 || m.BBox[3] > 40 { + t.Errorf("bbox looks wrong: %v", m.BBox) + } +} + +func keys(m map[string]CellMeta) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +} diff --git a/pkg/s57/catalog.go b/pkg/s57/catalog.go new file mode 100644 index 0000000..a2f9b9e --- /dev/null +++ b/pkg/s57/catalog.go @@ -0,0 +1,210 @@ +package s57 + +import ( + "bytes" + "io/fs" + "path" + "strconv" + "strings" + + "github.com/beetlebugorg/chartplotter/pkg/iso8211" +) + +// Catalog is the parsed contents of an S-57 exchange-set catalogue file +// (CATALOG.031): the directory of every file in the set, with each cell's +// long name and geographic coverage. It is the cheap way to learn a set's +// inventory and per-cell bounding boxes WITHOUT parsing every .000 cell. +type Catalog struct { + Entries []CatalogEntry +} + +// CatalogEntry is one CATD (catalogue directory) record. S-57 Appendix B.1: the +// CATD field lists RCNM, RCID, FILE, LFIL, VOLM, IMPL, SLAT, WLON, NLAT, ELON, +// CRCS, COMT. The catalogue is always ASCII (it must be readable without the +// DDR), so subfields are unit-terminator (0x1f) delimited, except the fixed-width +// RCNM(2)+RCID and IMPL(3) prefixes which run straight into the following subfield. +type CatalogEntry struct { + File string // path as recorded (e.g. "US5MD1MC\\US5MD1MC.000") + LongName string // LFIL — the human chart title (e.g. "Annapolis Harbor") + Impl string // "BIN" (a cell), "ASC", or "TXT" (auxiliary text) + CRC string // CRCS — the file's CRC (hex), if present + Comment string // COMT + HasBBox bool // true when SLAT/WLON/NLAT/ELON were all present + West float64 // WLON + South float64 // SLAT + East float64 // ELON + North float64 // NLAT +} + +// Base returns the file's basename with the path separators normalised +// (NOAA records "US5MD1MC\\US5MD1MC.000" with a backslash). +func (e CatalogEntry) Base() string { + f := strings.ReplaceAll(e.File, "\\", "/") + return path.Base(f) +} + +// IsCell reports whether this entry is a base ENC cell (a BIN .000 file) — the +// rows worth baking, as opposed to updates, text descriptions, or the catalogue +// itself. +func (e CatalogEntry) IsCell() bool { + return e.Impl == "BIN" && strings.HasSuffix(strings.ToUpper(e.Base()), ".000") +} + +// CellStem returns the cell name without extension (e.g. "US5MD1MC") for a cell +// entry, or "" if this entry is not a .000 cell. +func (e CatalogEntry) CellStem() string { + if !e.IsCell() { + return "" + } + b := e.Base() + return b[:len(b)-len(path.Ext(b))] +} + +// Cells returns just the base-cell entries (BIN .000), the inventory to bake. +func (c *Catalog) Cells() []CatalogEntry { + out := make([]CatalogEntry, 0, len(c.Entries)) + for _, e := range c.Entries { + if e.IsCell() { + out = append(out, e) + } + } + return out +} + +// Bounds returns the union bounding box [west, south, east, north] of every +// cell entry that carries coverage, and false if none did. +func (c *Catalog) Bounds() (bb [4]float64, ok bool) { + first := true + for _, e := range c.Entries { + if !e.HasBBox { + continue + } + if first { + bb = [4]float64{e.West, e.South, e.East, e.North} + first = false + ok = true + continue + } + bb[0] = min(bb[0], e.West) + bb[1] = min(bb[1], e.South) + bb[2] = max(bb[2], e.East) + bb[3] = max(bb[3], e.North) + } + return bb, ok +} + +// ParseCatalog parses a CATALOG.031 exchange-set catalogue from raw bytes. +func ParseCatalog(data []byte) (*Catalog, error) { + return parseCatalogISO(iso8211.MemFS{"/CATALOG.031": data}, "/CATALOG.031") +} + +// ParseCatalogFS parses a CATALOG.031 from a filesystem (e.g. an unzipped +// ENC_ROOT or os.DirFS), matching ParseFS for cells. +func ParseCatalogFS(fsys fs.FS, filename string) (*Catalog, error) { + return parseCatalogISO(fsys, filename) +} + +func parseCatalogISO(fsys fs.FS, filename string) (*Catalog, error) { + p, err := iso8211.OpenFS(fsys, filename) + if err != nil { + return nil, err + } + defer p.Close() + f, err := p.Parse() + if err != nil { + return nil, err + } + cat := &Catalog{} + for _, rec := range f.Records { + raw, ok := rec.Fields["CATD"] + if !ok { + continue + } + if e, ok := decodeCATD(raw); ok { + cat.Entries = append(cat.Entries, e) + } + } + return cat, nil +} + +// decodeCATD splits one CATD field into a CatalogEntry. Layout (NOAA/IHO ASCII +// catalogue, verified against a real NOAA CATALOG.031): +// +// [0] RCNM(2) + RCID(digits) + FILE [1] LFIL [2] VOLM +// [3] IMPL(3) + SLAT [4] WLON [5] NLAT [6] ELON [7] CRCS [8] COMT +// +// The bbox subfields are blank for non-cell files (TXT/ASC), so HasBBox gates +// on all four parsing. +func decodeCATD(raw []byte) (CatalogEntry, bool) { + // Trim the trailing field terminator (0x1e) the record carries. + raw = bytes.TrimRight(raw, "\x1e") + parts := strings.Split(string(raw), "\x1f") + if len(parts) < 4 { + return CatalogEntry{}, false + } + var e CatalogEntry + + // [0]: RCNM (2 chars) + RCID (run of digits) + FILE (remainder). + head := parts[0] + if len(head) < 2 { + return CatalogEntry{}, false + } + rest := head[2:] // drop RCNM ("CD") + i := 0 + for i < len(rest) && rest[i] >= '0' && rest[i] <= '9' { + i++ // consume RCID digits + } + e.File = rest[i:] + if e.File == "" { + return CatalogEntry{}, false + } + + e.LongName = parts[1] + // parts[2] is VOLM — not retained. + + // [3]: IMPL (3 chars: BIN/ASC/TXT) + SLAT (remainder, may be empty). + if len(parts) > 3 { + impl := parts[3] + if len(impl) >= 3 { + e.Impl = impl[:3] + slat := impl[3:] + wlon := field(parts, 4) + nlat := field(parts, 5) + elon := field(parts, 6) + if s, okS := parseFloat(slat); okS { + if w, okW := parseFloat(wlon); okW { + if n, okN := parseFloat(nlat); okN { + if ea, okE := parseFloat(elon); okE { + e.South, e.West, e.North, e.East = s, w, n, ea + e.HasBBox = true + } + } + } + } + } else { + e.Impl = impl + } + } + e.CRC = field(parts, 7) + e.Comment = field(parts, 8) + return e, true +} + +func field(parts []string, i int) string { + if i < len(parts) { + return parts[i] + } + return "" +} + +func parseFloat(s string) (float64, bool) { + s = strings.TrimSpace(s) + if s == "" { + return 0, false + } + v, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0, false + } + return v, true +} diff --git a/pkg/s57/catalog_test.go b/pkg/s57/catalog_test.go new file mode 100644 index 0000000..37c9026 --- /dev/null +++ b/pkg/s57/catalog_test.go @@ -0,0 +1,89 @@ +package s57 + +import ( + "os" + "testing" +) + +func TestParseCatalog_NOAA(t *testing.T) { + data, err := os.ReadFile("testdata/US5MD1MC_CATALOG.031") + if err != nil { + t.Fatal(err) + } + cat, err := ParseCatalog(data) + if err != nil { + t.Fatal(err) + } + + // The fixture is a single-cell NOAA exchange set: the catalogue itself, + // several .TXT descriptions, and one .000 base cell. + cells := cat.Cells() + if len(cells) != 1 { + t.Fatalf("want 1 base cell, got %d (%d total entries)", len(cells), len(cat.Entries)) + } + c := cells[0] + if c.CellStem() != "US5MD1MC" { + t.Errorf("CellStem = %q, want US5MD1MC", c.CellStem()) + } + if c.LongName != "Annapolis Harbor" { + t.Errorf("LongName = %q, want %q", c.LongName, "Annapolis Harbor") + } + if c.Impl != "BIN" { + t.Errorf("Impl = %q, want BIN", c.Impl) + } + if !c.HasBBox { + t.Fatal("cell entry should carry a bbox") + } + // From the CATD record: SLAT 38.925, WLON -76.5, NLAT 39.0, ELON -76.425. + wantBox := []struct { + name string + got float64 + want float64 + }{ + {"South", c.South, 38.925000}, + {"West", c.West, -76.500000}, + {"North", c.North, 39.000000}, + {"East", c.East, -76.425000}, + } + for _, b := range wantBox { + if d := b.got - b.want; d > 1e-6 || d < -1e-6 { + t.Errorf("%s = %f, want %f", b.name, b.got, b.want) + } + } + if c.CRC == "" { + t.Error("expected a CRCS value on the cell entry") + } + + // The auxiliary text descriptions are present but are NOT cells. + var txt int + for _, e := range cat.Entries { + if e.Impl == "TXT" { + txt++ + if e.HasBBox { + t.Errorf("TXT entry %s should have no bbox", e.Base()) + } + } + } + if txt == 0 { + t.Error("expected at least one TXT auxiliary entry") + } +} + +func TestCatalogBounds(t *testing.T) { + data, err := os.ReadFile("testdata/US5MD1MC_CATALOG.031") + if err != nil { + t.Fatal(err) + } + cat, err := ParseCatalog(data) + if err != nil { + t.Fatal(err) + } + bb, ok := cat.Bounds() + if !ok { + t.Fatal("expected a union bbox") + } + // Single cell → union equals that cell. + if bb != [4]float64{-76.5, 38.925, -76.425, 39.0} { + t.Errorf("Bounds = %v", bb) + } +} diff --git a/pkg/s57/testdata/US5MD1MC_CATALOG.031 b/pkg/s57/testdata/US5MD1MC_CATALOG.031 new file mode 100644 index 0000000..e4269e8 --- /dev/null +++ b/pkg/s57/testdata/US5MD1MC_CATALOG.031 @@ -0,0 +1 @@ +002623LE1 0900073 660400000000190000000001000048000019CATD0001220000670000;& 0001CATD0100;& ISO/IEC 8211 Record Identifier(I(5))1600;& Catalogue Directory FieldRCNM!RCID!FILE!LFIL!VOLM!IMPL!SLAT!WLON!NLAT!ELON!CRCS!COMT(A(2),I(10),3A,A(3),4R,2A)00101 D 00053 550400010000600000CATD000420000600000CD0000000001CATALOG.031V01X01ASC00119 D 00053 550400010000600000CATD000600000600001CD0000000002US5MD1MC\US348MCA.TXTV01X01TXT4D184C9500119 D 00053 550400010000600000CATD000600000600002CD0000000003US5MD1MC\US348MCB.TXTV01X01TXTAEF90FAD00119 D 00053 550400010000600000CATD000600000600003CD0000000004US5MD1MC\US348MCC.TXTV01X01TXTC99456D900119 D 00053 550400010000600000CATD000600000600004CD0000000005US5MD1MC\US348MCD.TXTV01X01TXT264EA56300119 D 00053 550400010000600000CATD000600000600005CD0000000006US5MD1MC\US348MCE.TXTV01X01TXT2E4F08D900173 D 00053 550400010000600000CATD001140000600006CD0000000007US5MD1MC\US5MD1MC.000Annapolis HarborV01X01BIN38.925000-76.50000039.000000-76.425000F8C4AAB700157 D 00053 550400010000600000CATD000980000600007CD0000000008US5MD1MC\US5MD1MC.001V01X01BIN38.925000-76.50000039.000000-76.425000568E254E00157 D 00053 550400010000600000CATD000980000600008CD0000000009US5MD1MC\US5MD1MC.002V01X01BIN38.925000-76.50000039.000000-76.42500049CB0B2E00157 D 00053 550400010000600000CATD000980000600009CD0000000010US5MD1MC\US5MD1MC.003V01X01BIN38.925000-76.50000039.000000-76.4250001EB8049500157 D 00053 550400010000600000CATD000980000600010CD0000000011US5MD1MC\US5MD1MC.004V01X01BIN38.925000-76.50000039.000000-76.425000ACEF66B500157 D 00053 550400010000600000CATD000980000600011CD0000000012US5MD1MC\US5MD1MC.005V01X01BIN38.925000-76.50000039.000000-76.425000CA98E705 \ No newline at end of file From 6a1b725ac0a225a07fa2bc5291448caecddf4170 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 22:15:48 -0400 Subject: [PATCH 2/5] feat(catalog): pack-identity + SetMeta assembly for per-upload library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-side metadata layer for "one pack per upload, named from CATALOG identity" (the chosen model). Pure + unit-tested; wires into the import path next. - catalogPackIdentity: derive a stable pack key from an exchange set — the longest common prefix of cell stems (US5MD1MC/US5MD2NW → "us5md"), a single cell → its full stem, no shared prefix → "" (caller falls back to the upload filename). slug() keeps it a valid set name. - buildSetMeta: assemble a pack's SetMeta from baker.ExtractCellMeta overlaid with the CATALOG.031 catalogue — catalogue LFIL supplies human chart titles and fills coverage bboxes the header lacks; aggregates the scale range, producing agency, union bbox, and cell count. - writeSetMeta/readSetMeta: .meta.json sidecar beside the archives. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/server/setmeta.go | 243 +++++++++++++++++++++++++ internal/engine/server/setmeta_test.go | 70 +++++++ 2 files changed, 313 insertions(+) create mode 100644 internal/engine/server/setmeta.go create mode 100644 internal/engine/server/setmeta_test.go diff --git a/internal/engine/server/setmeta.go b/internal/engine/server/setmeta.go new file mode 100644 index 0000000..3f890e0 --- /dev/null +++ b/internal/engine/server/setmeta.go @@ -0,0 +1,243 @@ +package server + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/beetlebugorg/chartplotter/internal/engine/baker" + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +// setMetaExt is the per-pack metadata sidecar written beside the band archives +// in the pack's setDir (e.g. /USER/US5MD1MC/user-us5md1mc.meta.json). +const setMetaExt = ".meta.json" + +// SetMeta is the per-pack metadata the chart library displays: the aggregate +// (title, agency, scale range, coverage, counts) plus the per-cell detail. Built +// at import time from the cells' S-57 headers (baker.ExtractCellMeta) overlaid +// with the exchange-set catalogue (CATALOG.031 long names + coverage). +type SetMeta struct { + Set string `json:"set"` + Title string `json:"title,omitempty"` + Agency string `json:"agency,omitempty"` + CellCount int `json:"cellCount"` + ScaleMin int `json:"scaleMin,omitempty"` // finest (smallest denom) + ScaleMax int `json:"scaleMax,omitempty"` // coarsest (largest denom) + BBox []float64 `json:"bbox,omitempty"` // [w,s,e,n] union, or nil + Imported string `json:"imported,omitempty"` // RFC3339, stamped by the caller + Cells []baker.CellMeta `json:"cells,omitempty"` +} + +// agencyName maps an IHO producing-agency code to a display name. Only the codes +// we expect from US ENC/IENC sources are named; others fall through to "Agency N". +func agencyName(code int) string { + switch code { + case 0: + return "" + case 550: + return "NOAA (US)" + default: + return "Agency " + itoa(code) + } +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var b [12]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + b[i] = '-' + } + return string(b[i:]) +} + +// catalogPackIdentity derives a stable, friendly pack identity from an +// exchange-set catalogue: the longest common (alphanumeric) prefix of the base +// cell names, lowercased — e.g. cells US5MD1MC/US5MD2NW → "us5md". Returns "" +// when there's no usable shared prefix (≥3 chars) so the caller can fall back to +// the upload filename. A single cell yields that cell's full stem. +func catalogPackIdentity(cat *s57.Catalog) string { + stems := make([]string, 0) + for _, c := range cat.Cells() { + if s := c.CellStem(); s != "" { + stems = append(stems, s) + } + } + return commonPrefixIdentity(stems) +} + +func commonPrefixIdentity(stems []string) string { + if len(stems) == 0 { + return "" + } + if len(stems) == 1 { + return slug(stems[0]) + } + sort.Strings(stems) + first, last := stems[0], stems[len(stems)-1] + n := 0 + for n < len(first) && n < len(last) && first[n] == last[n] { + n++ + } + prefix := first[:n] + if len(prefix) < 3 { + return "" + } + return slug(prefix) +} + +// slug lowercases and keeps only [a-z0-9-], collapsing other runs to nothing — +// safe for a set name (isSetName) and a directory component. +func slug(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } + } + return b.String() +} + +// buildSetMeta assembles a pack's SetMeta from the per-cell header metadata and +// (optionally) the exchange-set catalogue. The catalogue supplies human chart +// titles (LFIL) and a coverage bbox even for cells whose header lacks M_COVR. +func buildSetMeta(set string, cellMeta map[string]baker.CellMeta, cat *s57.Catalog) SetMeta { + // Catalogue overlay: stem → long name, stem → bbox. + catTitle := map[string]string{} + catBox := map[string][4]float64{} + if cat != nil { + for _, e := range cat.Cells() { + stem := e.CellStem() + if e.LongName != "" { + catTitle[stem] = e.LongName + } + if e.HasBBox { + catBox[stem] = [4]float64{e.West, e.South, e.East, e.North} + } + } + } + + m := SetMeta{Set: set} + agencyVotes := map[int]int{} + var haveBox bool + var bb [4]float64 + stems := make([]string, 0, len(cellMeta)) + for stem := range cellMeta { + stems = append(stems, stem) + } + sort.Strings(stems) + for _, stem := range stems { + c := cellMeta[stem] + if t := catTitle[stem]; t != "" { + c.Title = t // prefer the catalogue's human name over the dataset name + } + if !c.HasBBox { + if box, ok := catBox[stem]; ok { + c.BBox, c.HasBBox = box, true + } + } + if c.Scale > 0 { + if m.ScaleMin == 0 || c.Scale < m.ScaleMin { + m.ScaleMin = c.Scale + } + if c.Scale > m.ScaleMax { + m.ScaleMax = c.Scale + } + } + if c.Agency != 0 { + agencyVotes[c.Agency]++ + } + if c.HasBBox { + if !haveBox { + bb, haveBox = c.BBox, true + } else { + bb[0] = minF(bb[0], c.BBox[0]) + bb[1] = minF(bb[1], c.BBox[1]) + bb[2] = maxF(bb[2], c.BBox[2]) + bb[3] = maxF(bb[3], c.BBox[3]) + } + } + m.Cells = append(m.Cells, c) + } + m.CellCount = len(m.Cells) + if haveBox { + m.BBox = []float64{bb[0], bb[1], bb[2], bb[3]} + } + m.Agency = agencyName(topVote(agencyVotes)) + + // Title: a single cell → its chart name; otherwise the most common catalogue + // title if any cells share one, else the set name. + switch { + case len(m.Cells) == 1 && m.Cells[0].Title != "": + m.Title = m.Cells[0].Title + default: + m.Title = set + } + return m +} + +func topVote(votes map[int]int) int { + best, bestN := 0, 0 + for k, n := range votes { + if n > bestN { + best, bestN = k, n + } + } + return best +} + +func minF(a, b float64) float64 { + if a < b { + return a + } + return b +} + +func maxF(a, b float64) float64 { + if a > b { + return a + } + return b +} + +// writeSetMeta writes a pack's metadata sidecar into its setDir. Best-effort; a +// missing sidecar just means the library shows the pack without extracted detail. +func (s *Server) writeSetMeta(set string, m SetMeta) error { + dir := s.setDir(set) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(dir, set+setMetaExt), data, 0o644) +} + +// readSetMeta loads a pack's metadata sidecar, or (nil, false) if absent/unreadable. +func (s *Server) readSetMeta(set string) (*SetMeta, bool) { + data, err := os.ReadFile(filepath.Join(s.setDir(set), set+setMetaExt)) + if err != nil { + return nil, false + } + var m SetMeta + if json.Unmarshal(data, &m) != nil { + return nil, false + } + return &m, true +} diff --git a/internal/engine/server/setmeta_test.go b/internal/engine/server/setmeta_test.go new file mode 100644 index 0000000..892ee32 --- /dev/null +++ b/internal/engine/server/setmeta_test.go @@ -0,0 +1,70 @@ +package server + +import ( + "testing" + + "github.com/beetlebugorg/chartplotter/internal/engine/baker" + "github.com/beetlebugorg/chartplotter/pkg/s57" +) + +func TestCommonPrefixIdentity(t *testing.T) { + cases := []struct { + in []string + want string + }{ + {[]string{"US5MD1MC"}, "us5md1mc"}, // single cell → full stem + {[]string{"US5MD1MC", "US5MD2NW", "US5MD3SE"}, "us5md"}, // shared prefix + {[]string{"US5MD1MC", "GB5X01SW"}, ""}, // no usable prefix + {nil, ""}, + } + for _, c := range cases { + if got := commonPrefixIdentity(c.in); got != c.want { + t.Errorf("commonPrefixIdentity(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestBuildSetMeta_CatalogOverlay(t *testing.T) { + cellMeta := map[string]baker.CellMeta{ + "US5MD1MC": {Name: "US5MD1MC", Title: "US5MD1MC", Scale: 12000, Agency: 550, IssueDate: "20251030", BBox: [4]float64{-76.5, 38.9, -76.4, 39.0}, HasBBox: true}, + "US5MD2NW": {Name: "US5MD2NW", Title: "US5MD2NW", Scale: 20000, Agency: 550}, + } + cat := &s57.Catalog{Entries: []s57.CatalogEntry{ + {File: "US5MD1MC\\US5MD1MC.000", Impl: "BIN", LongName: "Annapolis Harbor", HasBBox: true, West: -76.5, South: 38.9, East: -76.4, North: 39.0}, + {File: "US5MD2NW\\US5MD2NW.000", Impl: "BIN", LongName: "Chesapeake Bay", HasBBox: true, West: -76.6, South: 39.0, East: -76.4, North: 39.2}, + }} + + m := buildSetMeta("user-us5md", cellMeta, cat) + + if m.CellCount != 2 { + t.Errorf("CellCount = %d, want 2", m.CellCount) + } + if m.ScaleMin != 12000 || m.ScaleMax != 20000 { + t.Errorf("scale range = [%d,%d], want [12000,20000]", m.ScaleMin, m.ScaleMax) + } + if m.Agency != "NOAA (US)" { + t.Errorf("Agency = %q, want NOAA (US)", m.Agency) + } + // Union bbox: catalogue supplied US5MD2NW's box (header had none). + if len(m.BBox) != 4 || m.BBox[1] != 38.9 || m.BBox[3] != 39.2 { + t.Errorf("BBox = %v, want union [-76.6,38.9,-76.4,39.2]", m.BBox) + } + // Catalogue long names overlay the dataset-name fallback. + titles := map[string]string{} + for _, c := range m.Cells { + titles[c.Name] = c.Title + } + if titles["US5MD1MC"] != "Annapolis Harbor" || titles["US5MD2NW"] != "Chesapeake Bay" { + t.Errorf("titles not overlaid from catalogue: %v", titles) + } +} + +func TestBuildSetMeta_SingleCellTitle(t *testing.T) { + cellMeta := map[string]baker.CellMeta{ + "US5MD1MC": {Name: "US5MD1MC", Title: "Annapolis Harbor", Scale: 12000}, + } + m := buildSetMeta("user-us5md1mc", cellMeta, nil) + if m.Title != "Annapolis Harbor" { + t.Errorf("Title = %q, want Annapolis Harbor", m.Title) + } +} From 4d59e35e772dc2a1b10f4481cd1bf0a38c1d94f0 Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 22:25:56 -0400 Subject: [PATCH 3/5] feat(catalog): wire per-upload metadata into the import path + API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each uploaded exchange set now becomes its own pack named from its CATALOG identity, with extracted metadata exposed to the library. - extractZipCells: stop dropping CATALOG.031 (its ".031" ext otherwise looked like an ENC update file) — parse it and return the *s57.Catalog. - handleImport: set=auto|"" derives a per-upload pack name via the CATALOG identity (longest common cell prefix → "user-"), uniquified against existing packs; falls back to the cells' prefix then "upload". - bakeAndRegister: after baking, ExtractCellMeta + buildSetMeta (catalogue titles + coverage overlaid) and write the .meta.json sidecar. - /api/packs merges title/agency/scale-range/cellCount/imported; new GET /api/pack/ returns the full per-cell detail. Delete cleans up the sidecar. Verified end to end against a real NOAA upload (US5MD1MC → "user-us5md1mc", "Annapolis Harbor", 1:12000, NOAA): /api/packs + /api/pack both correct. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/server/http.go | 2 + internal/engine/server/import.go | 133 +++++++++++++++++---- internal/engine/server/import_meta_test.go | 127 ++++++++++++++++++++ internal/engine/server/import_test.go | 7 +- internal/engine/server/tilesets.go | 45 +++++++ 5 files changed, 291 insertions(+), 23 deletions(-) create mode 100644 internal/engine/server/import_meta_test.go diff --git a/internal/engine/server/http.go b/internal/engine/server/http.go index 641c2e8..95397e0 100644 --- a/internal/engine/server/http.go +++ b/internal/engine/server/http.go @@ -262,6 +262,8 @@ func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) { s.handleImport(w, r) // POST: server-side native bake → register a tile set; status polling case r.URL.Path == "/api/packs": s.handlePacks(w, r) // GET: all baked packs + enabled state + case strings.HasPrefix(r.URL.Path, "/api/pack/"): + s.handlePackDetail(w, r) // GET: one pack's full extracted metadata (per-cell) case r.URL.Path == "/api/set/enable" || r.URL.Path == "/api/set/disable": s.handleSetEnabled(w, r) // POST: show/hide a pack on the map (data kept) case r.URL.Path == "/api/set": diff --git a/internal/engine/server/import.go b/internal/engine/server/import.go index 8b8d0d2..07c7184 100644 --- a/internal/engine/server/import.go +++ b/internal/engine/server/import.go @@ -19,6 +19,7 @@ import ( "github.com/beetlebugorg/chartplotter/internal/engine/baker" "github.com/beetlebugorg/chartplotter/internal/engine/pmtiles" "github.com/beetlebugorg/chartplotter/internal/engine/tilesource" + "github.com/beetlebugorg/chartplotter/pkg/s57" ) // Server-side import/bake. POST /api/import takes ENC input — an uploaded @@ -134,14 +135,18 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { } set := r.URL.Query().Get("set") - if !isSetName(set) { + // "auto" (or empty) means "name this upload from its CATALOG identity" — the + // one-pack-per-upload path. The real name is derived below, after the zip is + // parsed (we need its catalogue / cell names first). + autoName := set == "" || set == "auto" + if !autoName && !isSetName(set) { apiErr(w, http.StatusBadRequest, "set must be a valid name") return } overzoom := r.URL.Query().Get("overzoom") == "1" applyUpdates := r.URL.Query().Get("updates") != "0" // default: apply .001+ (NtM corrections) - cells, aux, err := s.importInputs(r) + cells, aux, cat, err := s.importInputs(r) if err != nil { apiErr(w, http.StatusBadRequest, err.Error()) return @@ -150,15 +155,57 @@ func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) { apiErr(w, http.StatusBadRequest, "no ENC base cells (.000) in input") return } + if autoName { + set = s.deriveUploadSet(cat, cells) + } job := s.imports.create(set) - go s.runImport(job.ID, set, cells, aux, overzoom, applyUpdates) + go s.runImport(job.ID, set, cells, aux, cat, overzoom, applyUpdates) w.Header().Set("Content-Type", jsonCT) w.WriteHeader(http.StatusAccepted) fmt.Fprintf(w, `{"ok":true,"job":%q,"set":%q}`, job.ID, set) } +// deriveUploadSet picks a stable, friendly pack name for an uploaded exchange set +// from its CATALOG identity (longest common cell-name prefix), falling back to the +// cells' shared prefix when there's no catalogue, then to "upload". Namespaced +// under the "user" provider and uniquified against existing packs. +func (s *Server) deriveUploadSet(cat *s57.Catalog, cells map[string]baker.CellData) string { + id := "" + if cat != nil { + id = catalogPackIdentity(cat) + } + if id == "" { + stems := make([]string, 0, len(cells)) + for n := range cells { + stems = append(stems, strings.TrimSuffix(n, ".000")) + } + id = commonPrefixIdentity(stems) + } + if id == "" { + id = "upload" + } + return s.uniqueSet("user-" + id) +} + +// uniqueSet returns base, or base-2/base-3/… if a pack (any band-set of that +// district) already exists, so a second upload of the same area doesn't clobber +// the first. +func (s *Server) uniqueSet(base string) string { + taken := func(name string) bool { return len(s.setsForDistrict(name)) > 0 } + if !taken(base) { + return base + } + for i := 2; i < 1000; i++ { + cand := base + "-" + itoa(i) + if !taken(cand) { + return cand + } + } + return base +} + // importFetchReq is the JSON body of a server-side download+bake. Either zipURL // (one NOAA exchange-set/district zip the server fetches + extracts) or cells (a // list of per-cell NOAA zip URLs) supplies the cells; the server downloads them @@ -233,6 +280,7 @@ func (s *Server) runImportFetch(jobID string, req importFetchReq) { var cells map[string]baker.CellData var aux map[string][]byte + var cat *s57.Catalog if req.ZipURL != "" { // Bulk: stream the one zip (byte progress), then extract + cache its cells. @@ -250,7 +298,7 @@ func (s *Server) runImportFetch(jobID string, req importFetchReq) { s.imports.update(jobID, func(j *importJob) { j.Phase, j.Unit, j.Note, j.Done, j.Total = "extract", "cells", "Extracting "+name, 0, 0 }) - cells, aux, err = extractZipCells(data) + cells, aux, cat, err = extractZipCells(data) if err != nil { fail(err) return @@ -304,7 +352,7 @@ func (s *Server) runImportFetch(jobID string, req importFetchReq) { bakeMap = s.cachedCellData(strings.Join(req.Bake, ",")) maps.Copy(bakeMap, cells) } - s.bakeAndRegister(jobID, req.Set, bakeMap, aux, req.Overzoom, applyUpdates) + s.bakeAndRegister(jobID, req.Set, bakeMap, aux, cat, req.Overzoom, applyUpdates) } // cacheCells writes each cell's base (+updates) into the ENC_ROOT cache layout so @@ -388,41 +436,41 @@ func fetchURLProgress(raw string, onProgress func(done, total int)) ([]byte, err // importInputs gathers the cells to bake: from an uploaded zip (raw zip body or a // multipart "file" field) when one is present, else from the ENC_ROOT cache // (optionally narrowed by ?cells=A,B,C). -func (s *Server) importInputs(r *http.Request) (map[string]baker.CellData, map[string][]byte, error) { +func (s *Server) importInputs(r *http.Request) (map[string]baker.CellData, map[string][]byte, *s57.Catalog, error) { ct := r.Header.Get("Content-Type") if strings.HasPrefix(ct, "multipart/form-data") { f, _, err := r.FormFile("file") if err != nil { - return nil, nil, fmt.Errorf("multipart: %w", err) + return nil, nil, nil, fmt.Errorf("multipart: %w", err) } defer f.Close() data, err := io.ReadAll(io.LimitReader(f, maxImportBytes)) if err != nil { - return nil, nil, err + return nil, nil, nil, err } return s.cacheExtracted(extractZipCells(data)) } body, err := io.ReadAll(io.LimitReader(r.Body, maxImportBytes)) if err != nil { - return nil, nil, err + return nil, nil, nil, err } if isZip(body) { return s.cacheExtracted(extractZipCells(body)) } // No (zip) body → bake from the cached cells (already on disk). - return s.cachedCellData(r.URL.Query().Get("cells")), nil, nil + return s.cachedCellData(r.URL.Query().Get("cells")), nil, nil, nil } // cacheExtracted persists freshly-extracted upload cells to the ENC_ROOT source // cache before baking, so the ORIGINAL cell files are always kept (re-bakeable // after a tile-cache wipe) rather than discarded after an in-memory bake. Passes // the (cells, aux, err) triple straight through. -func (s *Server) cacheExtracted(cells map[string]baker.CellData, aux map[string][]byte, err error) (map[string]baker.CellData, map[string][]byte, error) { +func (s *Server) cacheExtracted(cells map[string]baker.CellData, aux map[string][]byte, cat *s57.Catalog, err error) (map[string]baker.CellData, map[string][]byte, *s57.Catalog, error) { if err == nil && len(cells) > 0 { s.cacheCells(cells) } - return cells, aux, err + return cells, aux, cat, err } // maxImportBytes caps an uploaded exchange set (a single NOAA district zip is well @@ -470,8 +518,8 @@ func (s *Server) cachedCellData(csv string) map[string]baker.CellData { } // runImport bakes cells into /tiles/.pmtiles and registers the set. -func (s *Server) runImport(jobID, set string, cells map[string]baker.CellData, aux map[string][]byte, overzoom, applyUpdates bool) { - s.bakeAndRegister(jobID, set, cells, aux, overzoom, applyUpdates) +func (s *Server) runImport(jobID, set string, cells map[string]baker.CellData, aux map[string][]byte, cat *s57.Catalog, overzoom, applyUpdates bool) { + s.bakeAndRegister(jobID, set, cells, aux, cat, overzoom, applyUpdates) } // bakeAndRegister is the shared bake → write → register tail for every import @@ -481,7 +529,7 @@ func (s *Server) runImport(jobID, set string, cells map[string]baker.CellData, a // no-data hatch holes). Each band that produced tiles is written + registered as its // own set; the district aux.zip is written once (with the first band). Progress and // the terminal state are recorded on the job. -func (s *Server) bakeAndRegister(jobID, set string, cells map[string]baker.CellData, aux map[string][]byte, overzoom, applyUpdates bool) { +func (s *Server) bakeAndRegister(jobID, set string, cells map[string]baker.CellData, aux map[string][]byte, cat *s57.Catalog, overzoom, applyUpdates bool) { fail := func(err error) { log.Printf("import %s (%s): %v", jobID, set, err) s.imports.update(jobID, func(j *importJob) { j.State = "error"; j.Err = err.Error() }) @@ -537,6 +585,20 @@ func (s *Server) bakeAndRegister(jobID, set string, cells map[string]baker.CellD } s.imports.update(jobID, func(j *importJob) { j.Cells = nCells }) s.auxIdx.invalidate() // the district's companion aux.zip changed — re-index /api/aux + + // Per-pack metadata sidecar for the chart library: per-cell scale/edition/date/ + // agency/coverage (cheap coverage-only parse) overlaid with the catalogue's chart + // titles + coverage. Best-effort — a write failure only costs the extracted detail. + s.imports.update(jobID, func(j *importJob) { j.Phase, j.Note = "meta", "Reading chart metadata" }) + cellMeta := baker.ExtractCellMeta(cells, func(name string, e error) { + log.Printf("import %s: meta skip %s: %v", jobID, name, e) + }) + meta := buildSetMeta(set, cellMeta, cat) + meta.Imported = time.Now().UTC().Format(time.RFC3339) + if err := s.writeSetMeta(set, meta); err != nil { + log.Printf("import %s: write meta %q: %v", jobID, set, err) + } + log.Printf("import %s: baked district %q (%d cells, %d bands, %d tiles)", jobID, set, nCells, bands, tiles) s.imports.update(jobID, func(j *importJob) { j.State = "done" }) } @@ -708,10 +770,10 @@ func (s *Server) importEvents(w http.ResponseWriter, r *http.Request) { // extractZipCells reads an exchange-set zip held in memory, grouping each cell's // base (.000) + updates (.001…) by cell stem and collecting referenced aux files. // It mirrors the CLI's collectCells/addZipCells for an in-memory archive. -func extractZipCells(data []byte) (map[string]baker.CellData, map[string][]byte, error) { +func extractZipCells(data []byte) (map[string]baker.CellData, map[string][]byte, *s57.Catalog, error) { zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) if err != nil { - return nil, nil, fmt.Errorf("not a valid zip: %w", err) + return nil, nil, nil, fmt.Errorf("not a valid zip: %w", err) } type acc struct { base []byte @@ -719,20 +781,33 @@ func extractZipCells(data []byte) (map[string]baker.CellData, map[string][]byte, } byCell := map[string]*acc{} aux := map[string][]byte{} + var catalogBytes []byte // CATALOG.031 — parsed after the loop for per-cell metadata for _, e := range zr.File { - ext := encExtServer(e.Name) + // CATALOG.031 must be tested FIRST: its ".031" extension otherwise looks like + // an ENC update file to encExtServer and gets grouped as a baseless update. + isCat := isCatalogFile(e.Name) + ext := "" + if !isCat { + ext = encExtServer(e.Name) + } isAux := ext == "" && isAuxContentServer(e.Name) - if ext == "" && !isAux { + if !isCat && ext == "" && !isAux { continue } rc, err := e.Open() if err != nil { - return nil, nil, err + return nil, nil, nil, err } b, err := io.ReadAll(rc) rc.Close() if err != nil { - return nil, nil, err + return nil, nil, nil, err + } + if isCat { + if catalogBytes == nil { + catalogBytes = b + } + continue } if isAux { if k := strings.ToUpper(filepath.Base(e.Name)); aux[k] == nil { @@ -762,7 +837,21 @@ func extractZipCells(data []byte) (map[string]baker.CellData, map[string][]byte, } cells[stem+".000"] = baker.CellData{Base: a.base, Updates: a.updates} } - return cells, aux, nil + var cat *s57.Catalog + if catalogBytes != nil { + if c, err := s57.ParseCatalog(catalogBytes); err == nil { + cat = c + } else { + log.Printf("import: CATALOG.031 parse failed (ignored): %v", err) + } + } + return cells, aux, cat, nil +} + +// isCatalogFile reports whether a zip entry is an S-57 exchange-set catalogue +// (CATALOG.031). Matched by basename so it's found wherever it sits (ENC_ROOT/…). +func isCatalogFile(name string) bool { + return strings.HasPrefix(strings.ToUpper(filepath.Base(name)), "CATALOG.") } // (helpers below) diff --git a/internal/engine/server/import_meta_test.go b/internal/engine/server/import_meta_test.go new file mode 100644 index 0000000..3a8467b --- /dev/null +++ b/internal/engine/server/import_meta_test.go @@ -0,0 +1,127 @@ +package server + +import ( + "archive/zip" + "bytes" + "encoding/json" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/beetlebugorg/chartplotter/internal/engine/baker" +) + +// buildExchangeZip packs the committed real cell + CATALOG.031 fixtures into an +// in-memory ENC exchange-set zip, the shape an upload arrives as. +func buildExchangeZip(t *testing.T) []byte { + t.Helper() + cell, err := os.ReadFile("../../../testdata/US5MD1MC.000") + if err != nil { + t.Fatal(err) + } + cat, err := os.ReadFile("../../../pkg/s57/testdata/US5MD1MC_CATALOG.031") + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for name, data := range map[string][]byte{ + "ENC_ROOT/CATALOG.031": cat, + "ENC_ROOT/US5MD1MC/US5MD1MC.000": cell, + } { + f, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(data); err != nil { + t.Fatal(err) + } + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// TestImport_AutoNameAndMeta exercises the upload metadata wiring (minus HTTP and +// the bake): extract → derive a CATALOG-identity pack name → extract per-cell +// metadata → write the sidecar → surface it on /api/packs and /api/pack/. +// The bake itself needs the S-101 portrayer (-tags embed_s101) and is covered by +// the baker tests; this replicates the post-bake metadata tail of bakeAndRegister. +func TestImport_AutoNameAndMeta(t *testing.T) { + cacheDir, dataDir := t.TempDir(), t.TempDir() + s := New(t.TempDir(), cacheDir, dataDir, false) + + zipData := buildExchangeZip(t) + cells, _, cat, err := extractZipCells(zipData) + if err != nil { + t.Fatal(err) + } + if cat == nil { + t.Fatal("expected a parsed CATALOG.031 from the upload") + } + if len(cells) != 1 { + t.Fatalf("cells = %d, want 1", len(cells)) + } + + // CATALOG identity → single cell → "user-us5md1mc". + set := s.deriveUploadSet(cat, cells) + if set != "user-us5md1mc" { + t.Fatalf("deriveUploadSet = %q, want user-us5md1mc", set) + } + + // The post-bake metadata tail (bakeAndRegister does exactly this after baking). + cellMeta := baker.ExtractCellMeta(cells, nil) + meta := buildSetMeta(set, cellMeta, cat) + meta.Imported = "2026-06-25T00:00:00Z" + if err := s.writeSetMeta(set, meta); err != nil { + t.Fatal(err) + } + // Register a band-set so the district lists on /api/packs (a real bake does this + // via packAdd; the empty path makes the bounds-open skip gracefully). + s.packAdd(set+"-harbor", "") + + // The metadata sidecar carries the catalogue title + extracted header fields. + m, ok := s.readSetMeta(set) + if !ok { + t.Fatal("no metadata sidecar written") + } + if m.Title != "Annapolis Harbor" { + t.Errorf("Title = %q, want Annapolis Harbor", m.Title) + } + if m.Agency != "NOAA (US)" { + t.Errorf("Agency = %q, want NOAA (US)", m.Agency) + } + if m.CellCount != 1 || m.ScaleMin != 12000 { + t.Errorf("CellCount=%d ScaleMin=%d, want 1 / 12000", m.CellCount, m.ScaleMin) + } + if m.Imported == "" { + t.Error("expected an import timestamp") + } + if len(m.Cells) != 1 || m.Cells[0].Title != "Annapolis Harbor" { + t.Errorf("per-cell detail wrong: %+v", m.Cells) + } + + // /api/packs lists the pack with its merged metadata. + rec := httptest.NewRecorder() + s.handlePacks(rec, httptest.NewRequest("GET", "/api/packs", nil)) + body := rec.Body.String() + if !strings.Contains(body, `"name":"user-us5md1mc"`) || !strings.Contains(body, `"title":"Annapolis Harbor"`) { + t.Errorf("/api/packs missing pack or title: %s", body) + } + + // /api/pack/ returns the full detail incl. per-cell list. + rec = httptest.NewRecorder() + s.handlePackDetail(rec, httptest.NewRequest("GET", "/api/pack/"+set, nil)) + if rec.Code != 200 { + t.Fatalf("pack detail status %d: %s", rec.Code, rec.Body.String()) + } + var got SetMeta + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("pack detail JSON: %v", err) + } + if got.Set != set || len(got.Cells) != 1 { + t.Errorf("pack detail = %+v", got) + } +} diff --git a/internal/engine/server/import_test.go b/internal/engine/server/import_test.go index 81aac09..887513f 100644 --- a/internal/engine/server/import_test.go +++ b/internal/engine/server/import_test.go @@ -43,10 +43,15 @@ func TestExtractZipCells(t *testing.T) { "ENC_ROOT/README.TXT": []byte("readme"), // excluded "ENC_ROOT/US5MD1MC/US5MD1MC.TXT": []byte("desc"), // aux text }) - cells, aux, err := extractZipCells(z) + cells, aux, cat, err := extractZipCells(z) if err != nil { t.Fatalf("extractZipCells: %v", err) } + // The bogus CATALOG.031 bytes aren't a valid ISO 8211 file → parse fails + // gracefully to a nil catalogue (logged, ignored), not an error. + if cat != nil { + t.Errorf("expected nil catalogue from unparseable CATALOG.031, got %+v", cat) + } if len(cells) != 2 { t.Fatalf("cells = %d, want 2", len(cells)) } diff --git a/internal/engine/server/tilesets.go b/internal/engine/server/tilesets.go index 59c127a..46994a2 100644 --- a/internal/engine/server/tilesets.go +++ b/internal/engine/server/tilesets.go @@ -1,6 +1,7 @@ package server import ( + "encoding/json" "fmt" "io" "math" @@ -172,6 +173,11 @@ func (s *Server) handleDeleteSet(w http.ResponseWriter, r *http.Request) { _ = os.Remove(filepath.Join(dir, name+".aux.zip")) _ = os.Remove(dir) // best-effort: drop the pack dir if now empty } + // Drop the district's metadata sidecar (.meta.json), which lives in + // the district's own setDir, separate from the per-band dirs above. + mdir := s.setDir(set) + _ = os.Remove(filepath.Join(mdir, set+setMetaExt)) + _ = os.Remove(mdir) s.auxIdx.invalidate() // a district's companion aux.zip is gone — re-index /api/aux w.Header().Set("Content-Type", jsonCT) io.WriteString(w, `{"ok":true}`) @@ -236,6 +242,26 @@ func (s *Server) handlePacks(w http.ResponseWriter, r *http.Request) { if p.hasBounds { fmt.Fprintf(w, `,"bounds":[%g,%g,%g,%g]`, p.w, p.s, p.e, p.n) } + // Extracted per-pack metadata (title/agency/scale range/counts/imported date), + // from the .meta.json sidecar written at import. Cells are omitted from + // the list view — fetch GET /api/pack/ for the full per-cell detail. + if m, ok := s.readSetMeta(d); ok { + if m.Title != "" { + fmt.Fprintf(w, `,"title":%q`, m.Title) + } + if m.Agency != "" { + fmt.Fprintf(w, `,"agency":%q`, m.Agency) + } + if m.CellCount > 0 { + fmt.Fprintf(w, `,"cellCount":%d`, m.CellCount) + } + if m.ScaleMin > 0 { + fmt.Fprintf(w, `,"scaleMin":%d,"scaleMax":%d`, m.ScaleMin, m.ScaleMax) + } + if m.Imported != "" { + fmt.Fprintf(w, `,"imported":%q`, m.Imported) + } + } fmt.Fprint(w, `,"bands":[`) for j, band := range p.bands { if j > 0 { @@ -248,6 +274,25 @@ func (s *Server) handlePacks(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "]}") } +// handlePackDetail returns the full extracted metadata for one pack, including the +// per-cell list (GET /api/pack/). 404s when the pack has no metadata sidecar +// (e.g. baked before metadata extraction existed, or a built-in pack). +func (s *Server) handlePackDetail(w http.ResponseWriter, r *http.Request) { + const prefix = "/api/pack/" + name := strings.TrimPrefix(r.URL.Path, prefix) + if name == "" || !isSetName(name) { + apiErr(w, http.StatusBadRequest, "bad pack name") + return + } + m, ok := s.readSetMeta(name) + if !ok { + apiErr(w, http.StatusNotFound, "no metadata for pack") + return + } + w.Header().Set("Content-Type", jsonCT) + _ = json.NewEncoder(w).Encode(m) +} + // handleSetEnabled shows or hides a pack on the map (POST /api/set/enable|disable // ?set=NAME). The baked data is kept; disabling just unregisters it so /tiles/{set} // stops serving + the client stops rendering it. Persists to prefs. From f4ce9f524f365879baea65da45da7a082036d7cd Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 22:43:38 -0400 Subject: [PATCH 4/5] feat(charts): server-side per-upload chart library; remove OPFS store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the chart-library feature on the client: each upload is its own pack with the metadata the backend extracts, and the in-browser OPFS cell store is gone (everything server-side). - chart-service.mjs: importZip()/importZipAndWait() (whole-zip multipart → /api/import?set=auto) and packDetail() (GET /api/pack/). - chart-library.mjs: drop the OPFS store + the in-browser zip cell-selection flow (_archive/_selected/importSelected/renderArchiveList/_refreshCharts). A .zip uploads whole (server names + bakes one pack); a lone .000 uploads + auto-bakes. User packs render from /api/packs with title/scale/agency/counts and a coverage preview; per-cell detail (title/scale/edition/date) loads lazily from /api/pack/. - chart-library.view.mjs: retire archiveList; refresh the import hints. - chartplotter.mjs: remove ChartStore; seed _installed from /api/cells (via _renderInstalledSets); reroute share-restore to bake a "user-shared" server pack instead of caching cells in OPFS. Delete the dead chart-store.mjs. Backend verified end-to-end earlier; the library UI + share-restore want an in-browser check (make serve). go test ./... + web tests + node --check green. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/chartplotter.mjs | 59 +++---- web/src/data/chart-service.mjs | 30 ++++ web/src/data/chart-store.mjs | 175 ------------------- web/src/plugins/chart-library.mjs | 233 +++++++++++-------------- web/src/plugins/chart-library.view.mjs | 17 +- 5 files changed, 157 insertions(+), 357 deletions(-) delete mode 100644 web/src/data/chart-store.mjs diff --git a/web/src/chartplotter.mjs b/web/src/chartplotter.mjs index e7fb5a4..43eecae 100644 --- a/web/src/chartplotter.mjs +++ b/web/src/chartplotter.mjs @@ -14,7 +14,7 @@ // (offline GSHHG); "none" disables the underlay (test charts) // // Everything is driven through the renderer's public API (bakePmtiles/setArchive/ -// listCharts/setScheme/setMariner and its `map` handle) plus the shared ChartStore. +// listCharts/setScheme/setMariner and its `map` handle) plus the server chart API. import "./chart-canvas/chart-canvas.mjs"; // defines (the renderer we wrap) import "./plugins/pick-report.mjs"; // defines (the ECDIS cursor-pick panel) @@ -34,7 +34,6 @@ import { ChartDownloader } from "./data/chart-downloader.mjs"; // chart discover import { NotificationCenter } from "./core/notification-center.mjs"; // app-level task-progress + banner bus import { ChartService } from "./data/chart-service.mjs"; // server import/bake jobs + pack registry import { AuxStore } from "./data/aux-store.mjs"; // TXTDSC/PICREP external files (companion aux zip) -import { ChartStore } from "./data/chart-store.mjs"; import { UNIT_DEFAULTS } from "./lib/units.mjs"; // configurable display units (categories now in core-settings.mjs) import { ChartFinder } from "./plugins/chart-finder.mjs"; // off-screen installed-chart edge pointers import { HudController } from "./plugins/hud.mjs"; // status readout + overscale zoom cap @@ -296,11 +295,11 @@ export class ChartPlotter extends HTMLElement { // a device with many charts) — the renderer boots with an empty `charts` // attribute so the map/basemap paints immediately, then we ingest cells // lazily by viewport (see ingestViewport). - this._store = new ChartStore(); - // Installed cells/sets are SERVER-side now (the XDG data/cache); onReady() calls - // _renderInstalledSets() to load them (GET /tiles/ + /api/cells), so the map - // survives a reload. Seed from the local OPFS store for the first paint. - this._installed = new Set(await this._store.list().catch(() => [])); + // Installed cells/sets are SERVER-side (the XDG data/cache). onReady() calls + // _renderInstalledSets(), which loads the registry (GET /api/packs) and the + // installed-cell set (GET /api/cells) and renders them — so it survives a reload. + // Starts empty for the first paint; _renderInstalledSets fills it. + this._installed = new Set(); this._installedSets = new Set(); this._disabled = new Set(); // packs hidden from the map (server-side; loaded in _renderInstalledSets) // Chart discovery/acquisition domain (NOAA catalogue, packs, download, import). @@ -308,7 +307,6 @@ export class ChartPlotter extends HTMLElement { this._dl = new ChartDownloader({ assets: this._assets, cfg: (n) => this._cfg(n), - store: this._store, getInstalled: () => this._installed, }); @@ -332,7 +330,7 @@ export class ChartPlotter extends HTMLElement { // .pmtiles archive path (the plotter is shell-owned). this._chartLib = this.shadowRoot.getElementById("chart-lib"); if (this._chartLib) { - this._chartLib.configure({ dl: this._dl, api: this._api, notify: this._notify, store: this._store, assets: this._assets, widget: this._widget }); + this._chartLib.configure({ dl: this._dl, api: this._api, notify: this._notify, assets: this._assets, widget: this._widget }); this._chartLib.setHiddenCells([...this._hiddenCells]); // seed checkbox state from persisted prefs this._chartLib.addEventListener("charts-changed", () => { this._renderInstalledSets().catch(() => {}); }); // Per-cell show/hide: apply the client filter to the live map + persist. @@ -507,8 +505,8 @@ export class ChartPlotter extends HTMLElement { this._map = map; this._resolveReady(); // Share-restore carries bearing/pitch too (center+zoom were applied as the - // initial camera). The installed cells were already added to _installed in - // boot(), so the loadStoreCells below bakes them at the restored viewport. + // initial camera). The shared cells were baked into the "user-shared" pack in + // _loadSharedView(); _renderInstalledSets() below renders it. if (this._sharePending) { const v = this._sharePending; this._sharePending = null; try { map.jumpTo({ bearing: v.bearing || 0, pitch: v.pitch || 0 }); } catch (e) { console.warn("[share] camera", e); } @@ -1171,29 +1169,29 @@ export class ChartPlotter extends HTMLElement { } // Fetch the latest shared snapshot and install its cells locally, downloading - // any not already stored through the server (which serves them from its cache — - // including bytes the publisher uploaded — or fetches the NOAA url). Returns the - // snapshot's camera ({center,zoom,...}) for boot() to use as the initial view; - // bearing/pitch are stashed for onReady. Cells are added to _installed so the - // normal loadStoreCells/lazy-bake path renders them. + // The cells are baked server-side into a "user-shared" pack (the server fetches + // each from its cache or the NOAA url), which _renderInstalledSets then renders + // like any other pack. Returns the snapshot's camera ({center,zoom,...}) for boot() + // as the initial view; bearing/pitch are stashed for onReady. async _loadSharedView() { const resp = await fetch("api/share", { cache: "no-store" }); if (!resp.ok) throw new Error("snapshot HTTP " + resp.status); const snap = await resp.json(); const cells = Array.isArray(snap.cells) ? snap.cells : []; + const specs = []; for (const cell of cells) { const n = typeof cell === "string" ? cell : (cell && cell.n); if (!n) continue; + specs.push({ name: n, url: (cell && cell.z) || "" }); + this._installed.add(n); + } + // Bake the shared cells into a single server pack so they render through the + // normal installed-set path. Best-effort: a failure just leaves the view empty. + if (specs.length && this._api) { try { - if (!(await this._store.has(n))) { - const z = cell && cell.z ? cell.z : ""; - const url = "api/cell/" + encodeURIComponent(n) + (z ? "?url=" + encodeURIComponent(z) : ""); - const r = await fetch(url); - if (!r.ok) throw new Error("HTTP " + r.status); - await this._store.put(n, new Uint8Array(await r.arrayBuffer())); - } - this._installed.add(n); - } catch (e) { console.warn("[share] install cell", n, e); } + const { job } = await this._api.import({ set: "user-shared", cells: specs }); + await this._api.pollJob(job, { name: "shared view" }); + } catch (e) { console.warn("[share] bake shared view", e); } } const view = snap.view || null; this._sharePending = view; // onReady applies bearing/pitch @@ -1459,8 +1457,8 @@ export class ChartPlotter extends HTMLElement { // The User-Charts local-file import (openFiles + the OPFS upload/bake path) now // lives in ; a dropped .pmtiles is handed back to the shell via - // the "chart-import-archive" event (see _importArchiveFile). The shell's debug - // tools still re-bake the local store through the component's _refreshCharts. + // the "chart-import-archive" event (see _importArchiveFile). Uploads bake into + // their own server pack; the dev tools re-bake installed packs via /api/import. // Render every baked tile set the server has (GET /tiles/) — each a provider/pack // (noaa-d17, ienc-…, import). This is the single source of truth for what's @@ -1494,13 +1492,6 @@ export class ChartPlotter extends HTMLElement { return active; } - // Re-bake the local OPFS store on the server (the User-Charts import path). The - // component owns this; the shell's debug tools delegate to it. - // Returns a resolved promise when there's no component yet (boot/widget guards). - _refreshCharts() { - return this._chartLib ? this._chartLib._refreshCharts() : Promise.resolve(); - } - // Wait for a server job (download/bake) to complete, surfacing progress through // prog({label,sub,frac}). Prefers a single Server-Sent-Events stream (one // connection, server pushes on change) and falls back to polling if EventSource diff --git a/web/src/data/chart-service.mjs b/web/src/data/chart-service.mjs index 65f5206..56ff5d1 100644 --- a/web/src/data/chart-service.mjs +++ b/web/src/data/chart-service.mjs @@ -73,6 +73,27 @@ export class ChartService { return this.pollJob(job, opts); } + // POST /api/import?set=auto (multipart) — upload a whole ENC exchange-set zip; + // the server parses its CATALOG.031, names the pack from its identity, bakes it, + // and writes the metadata sidecar. Returns {job, set} (the server-derived name). + // `set` defaults to "auto"; pass a name to override. + async importZip(file, { set = "auto" } = {}) { + const form = new FormData(); + form.append("file", file, file.name || "upload.zip"); + const res = await fetch(this._url(`api/import?set=${encodeURIComponent(set)}`), { method: "POST", body: form }); + const j = await res.json().catch(() => ({})); + if (!res.ok || !j.job) throw new Error(j.error || `import HTTP ${res.status}`); + return j; // {ok, job, set} + } + + // upload a zip and wait for the bake. Resolves with { status, set } so the + // caller knows the server-derived pack name. + async importZipAndWait(file, opts) { + const { job, set } = await this.importZip(file, opts); + const status = await this.pollJob(job, opts); + return { status, set }; + } + // Wait for a job, surfacing UI-ready progress via opts.onStatus({label,sub,frac}). // Prefers a single SSE stream; falls back to 500ms polling (~20min ceiling). async pollJob(job, { name, onStatus = () => {} } = {}) { @@ -134,6 +155,15 @@ export class ChartService { catch (e) { return []; } } + // GET /api/pack/ — one pack's full extracted metadata, incl. the per-cell + // list (title/scale/edition/date/agency/bbox). null when the pack has no sidecar + // (built-in packs, or one baked before metadata extraction). Used for the + // per-upload detail view. + async packDetail(name) { + try { return await fetch(this._url(`api/pack/${encodeURIComponent(name)}`)).then((r) => (r.ok ? r.json() : null)); } + catch (e) { return null; } + } + // GET /api/cells — the set of installed cell names. Returns a Set; null on failure // (so callers can keep their current view rather than blanking it). async cells() { diff --git a/web/src/data/chart-store.mjs b/web/src/data/chart-store.mjs deleted file mode 100644 index b4e70cf..0000000 --- a/web/src/data/chart-store.mjs +++ /dev/null @@ -1,175 +0,0 @@ -// Persistent ENC cell store, with three backends picked automatically: -// -// 1. OPFS — origin-private filesystem; only in a SECURE CONTEXT -// (HTTPS or localhost). The most efficient for large blobs. -// 2. IndexedDB — works over plain http:// too (e.g. a LAN IP like a boat's -// 192.168.x.x device), where OPFS is unavailable. This is -// what keeps charts after a refresh on an insecure origin. -// 3. in-memory — last resort if neither exists; session-only (lost on -// reload). The plotter still renders. -// -// Cells are stored once and read back, so after the first import the plotter is -// self-contained and works fully offline with no server. The engine ingests the -// Uint8Array bytes this store hands it. -// -// `download` fetches same-origin today (a static host serving raw .000 files); -// the in-browser .zip import (see zip-import.mjs) is the no-server path. Pulling -// NOAA .zip cells directly is a follow-up: charts.noaa.gov has no CORS headers. - -const DIR = "cells"; -const DB_NAME = "chartplotter"; -const DB_STORE = "cells"; - -const HAS_OPFS = - typeof navigator !== "undefined" && - navigator.storage && - typeof navigator.storage.getDirectory === "function"; -const HAS_IDB = typeof indexedDB !== "undefined"; - -// -- OPFS backend (secure context) ------------------------------------------ -async function cellsDir() { - const root = await navigator.storage.getDirectory(); - return root.getDirectoryHandle(DIR, { create: true }); -} -const opfsBackend = { - async has(name) { - try { const d = await cellsDir(); await d.getFileHandle(name + ".000"); return true; } - catch { return false; } - }, - async get(name) { - const d = await cellsDir(); - const fh = await d.getFileHandle(name + ".000"); - return new Uint8Array(await (await fh.getFile()).arrayBuffer()); - }, - async put(name, bytes) { - const d = await cellsDir(); - const fh = await d.getFileHandle(name + ".000", { create: true }); - const w = await fh.createWritable(); - await w.write(bytes); - await w.close(); - }, - async list() { - const d = await cellsDir(); - const out = []; - for await (const [n] of d.entries()) if (n.endsWith(".000")) out.push(n.slice(0, -4)); - return out.sort(); - }, - async remove(name) { - const d = await cellsDir(); - await d.removeEntry(name + ".000").catch(() => {}); - }, - async usage() { - const d = await cellsDir(); - let bytes = 0, count = 0; - for await (const [n, h] of d.entries()) { - if (!n.endsWith(".000") || h.kind !== "file") continue; - try { bytes += (await h.getFile()).size; count++; } catch {} // file.size is metadata, no read - } - return { bytes, count }; - }, -}; - -// -- IndexedDB backend (works on plain http) -------------------------------- -let _dbPromise = null; -function openDB() { - if (_dbPromise) return _dbPromise; - _dbPromise = new Promise((resolve, reject) => { - const req = indexedDB.open(DB_NAME, 1); - req.onupgradeneeded = () => req.result.createObjectStore(DB_STORE); - req.onsuccess = () => resolve(req.result); - req.onerror = () => reject(req.error); - }); - return _dbPromise; -} -function reqDone(req) { - return new Promise((res, rej) => { req.onsuccess = () => res(req.result); req.onerror = () => rej(req.error); }); -} -function txDone(tx) { - return new Promise((res, rej) => { tx.oncomplete = () => res(); tx.onerror = () => rej(tx.error); tx.onabort = () => rej(tx.error); }); -} -const idbBackend = { - async has(name) { - const db = await openDB(); - const k = await reqDone(db.transaction(DB_STORE).objectStore(DB_STORE).getKey(name)); - return k !== undefined; - }, - async get(name) { - const db = await openDB(); - const v = await reqDone(db.transaction(DB_STORE).objectStore(DB_STORE).get(name)); - if (!v) throw new Error("cell not stored: " + name); - return v instanceof Uint8Array ? v : new Uint8Array(v); - }, - async put(name, bytes) { - const db = await openDB(); - const tx = db.transaction(DB_STORE, "readwrite"); - tx.objectStore(DB_STORE).put(bytes, name); - await txDone(tx); - }, - async list() { - const db = await openDB(); - const keys = await reqDone(db.transaction(DB_STORE).objectStore(DB_STORE).getAllKeys()); - return keys.map(String).sort(); - }, - async remove(name) { - const db = await openDB(); - const tx = db.transaction(DB_STORE, "readwrite"); - tx.objectStore(DB_STORE).delete(name); - await txDone(tx); - }, - async usage() { - const db = await openDB(); - const vals = await reqDone(db.transaction(DB_STORE).objectStore(DB_STORE).getAll()); - let bytes = 0; - for (const v of vals) bytes += v.byteLength || v.length || 0; - return { bytes, count: vals.length }; - }, -}; - -// -- in-memory backend (last resort, session-only) -------------------------- -function memBackend() { - const m = new Map(); - return { - async has(name) { return m.has(name); }, - async get(name) { const b = m.get(name); if (!b) throw new Error("cell not stored: " + name); return b; }, - async put(name, bytes) { m.set(name, bytes); }, - async list() { return [...m.keys()].sort(); }, - async remove(name) { m.delete(name); }, - async usage() { let bytes = 0; for (const b of m.values()) bytes += b.byteLength || b.length || 0; return { bytes, count: m.size }; }, - }; -} - -export class ChartStore { - constructor() { - if (HAS_OPFS) { this.backend = opfsBackend; this.kind = "opfs"; } - else if (HAS_IDB) { this.backend = idbBackend; this.kind = "indexeddb"; } - else { this.backend = memBackend(); this.kind = "memory"; } - // True when reloads keep the data (OPFS or IndexedDB); false for in-memory. - this.persistent = this.kind !== "memory"; - } - - has(name) { return this.backend.has(name); } - getBytes(name) { return this.backend.get(name); } - put(name, bytes) { return this.backend.put(name, bytes); } - list() { return this.backend.list(); } - remove(name) { return this.backend.remove(name); } - // Total bytes + count of stored raw cells on disk (best-effort; OPFS uses file - // metadata, IndexedDB sums value sizes). - usage() { return this.backend.usage(); } - - // Download a cell's bytes from `url` into local storage and return them. - // Accepts a raw .000 today; NOAA ZIP unwrap is handled by the in-browser - // importer (zip-import.mjs), not here. - async download(name, url) { - const res = await fetch(url); - if (!res.ok) throw new Error(`download ${name}: HTTP ${res.status}`); - const bytes = new Uint8Array(await res.arrayBuffer()); - await this.put(name, bytes); - return bytes; - } - - // Return a cell's bytes, downloading it via `urlFor(name)` if not yet stored. - async ensure(name, urlFor) { - if (await this.has(name)) return this.getBytes(name); - return this.download(name, urlFor(name)); - } -} diff --git a/web/src/plugins/chart-library.mjs b/web/src/plugins/chart-library.mjs index 08997da..6a56797 100644 --- a/web/src/plugins/chart-library.mjs +++ b/web/src/plugins/chart-library.mjs @@ -26,14 +26,13 @@ // client-side archive path (plotter-coupled, so it // stays in the shell; see chartplotter.mjs). -import { esc, fmtIssue } from "../lib/util.mjs"; -import { readCentralDirectory, cellEntries, extractEntry } from "../data/zip-import.mjs"; +import { esc, fmtIssue, fmtScale } from "../lib/util.mjs"; import { seaColor, landColor, coastColor } from "../chart-canvas/s52-style.mjs"; // our own basemap palette (consistent with the chart) import { STYLE, widgetBody, libraryBody, packSearch, providersCol, packsHeader, packBadge, userPackRow, packRow, packsCol, emptyRow, downloadBtn, detailEmpty, detailUnknownSet, detailPack, installedActions, previewMapHost, - importDetail, dataFreshness, agreementModal, archiveList, millerBack, packCellList, + importDetail, dataFreshness, agreementModal, millerBack, packCellList, } from "./chart-library.view.mjs"; // NOAA ENC User Agreement acceptance (localStorage). Exported so the shell can @@ -86,7 +85,6 @@ export class ChartLibrary extends HTMLElement { this._dl = null; // ChartDownloader (NOAA catalogue/discovery) this._api = null; // ChartService (server import/bake + pack registry) this._notify = null; // NotificationCenter (task progress + banners) - this._store = null; // ChartStore (OPFS local cell store, for User imports) this._assets = "./"; // Selection state for the 3-pane drill-down. @@ -116,15 +114,13 @@ export class ChartLibrary extends HTMLElement { this._disabled = new Set(); this._installed = new Set(); // installed cell names (for the NOAA pack counts) this._hiddenCells = new Set(); // cell names hidden from the map (per-cell toggle); owned by the shell, mirrored here for render + this._packMeta = new Map(); // pack name → /api/packs entry (title/agency/scale/cellCount/imported/bounds) + this._packDetail = new Map(); // pack name → /api/pack/ detail (per-cell list), fetched lazily on select // NOAA ENC agreement acceptance (persisted). this._agreed = localStorage.getItem(LS_AGREE) === "1"; this._agreeResolve = null; - // Local-file import scratch (the User-Charts path). - this._archive = new Map(); // cell name -> {blob, entry, updates} from opened zips - this._selected = new Set(); // cell names ticked for import - this._previewMap = null; // live preview map (unused; kept for safe teardown) this._previewCache = new Map(); // pack key → coverage snapshot dataURL (rendered once) this._previewKey = null; // pack key the detail preview currently targets @@ -153,11 +149,10 @@ export class ChartLibrary extends HTMLElement { // Inject dependencies (call once after creation). `widget` flips the Library to // import-only (no NOAA download/region picker), matching the shell's widget mode. - configure({ dl, api, notify, store, assets, widget } = {}) { + configure({ dl, api, notify, assets, widget } = {}) { this._dl = dl || null; this._api = api || null; this._notify = notify || null; - this._store = store || null; if (assets) this._assets = assets; this._widget = !!widget; return this; @@ -211,6 +206,9 @@ export class ChartLibrary extends HTMLElement { const packs = await this._api.packs(); this._installedSets = new Set(packs.map((p) => p.name)); this._disabled = new Set(packs.filter((p) => !p.enabled).map((p) => p.name)); + // Keep each pack's extracted metadata (title/agency/scale/cellCount/imported/ + // bounds) for the User-Charts rows + detail — the per-upload info display. + this._packMeta = new Map(packs.map((p) => [p.name, p])); } catch (e) { /* keep last */ } try { const cells = await this._api.cells(); if (cells) this._installed = cells; } catch (e) { /* keep last */ } } @@ -243,8 +241,26 @@ export class ChartLibrary extends HTMLElement { }).filter(Boolean); } if (id === "ienc") return this._iencPacks() || []; - // user: locally-imported packs (anything not NOAA/IENC). - return [...sets].filter((n) => !/^(noaa-d\d+|ienc-)/.test(n)).sort().map((n) => ({ key: n, kind: "user", title: this._setLabel(n), sub: "installed", installed: true })); + // user: uploaded packs (anything not NOAA/IENC), with extracted metadata. + return [...sets].filter((n) => !/^(noaa-d\d+|ienc-)/.test(n)).sort().map((n) => { + const m = this._packMeta.get(n) || {}; + return { + key: n, kind: "user", installed: true, + title: m.title || this._setLabel(n), + sub: this._userPackSub(m), + bbox: Array.isArray(m.bounds) ? m.bounds : null, + meta: m, + }; + }); + } + + // One-line summary for a user pack row: chart count, scale (range), agency. + _userPackSub(m) { + const parts = []; + if (m.cellCount) parts.push(`${m.cellCount} chart${m.cellCount > 1 ? "s" : ""}`); + if (m.scaleMin) parts.push(m.scaleMax && m.scaleMax !== m.scaleMin ? `1:${fmtScale(m.scaleMin)}–1:${fmtScale(m.scaleMax)}` : `1:${fmtScale(m.scaleMin)}`); + if (m.agency) parts.push(m.agency); + return parts.join(" · ") || "installed"; } // USACE Inland ENC catalogue (server-fetched + parsed). Cached here once via @@ -545,16 +561,25 @@ export class ChartLibrary extends HTMLElement { sub = d ? `${esc(d.name)} · ${esc(d.blurb)}` : ""; meta = `${pk.sub} · outlined area below is the coverage`; } else if (pk.kind === "user") { + const m = pk.meta || this._packMeta.get(key) || {}; title = pk.title || this._setLabel(key); - sub = "Imported charts — baked on the server, kept under User Charts."; - meta = ""; + sub = this._userPackSub(m); + const det = this._packDetail.get(key); + const ed = det && det.cells && det.cells[0]; // single-cell editions/dates, when applicable + const ymd = (s) => (/^\d{8}$/.test(s) ? `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}` : s); // S-57 YYYYMMDD → ISO + const bits = []; + if (m.imported) bits.push(`imported ${fmtIssue(m.imported.slice(0, 10))}`); + if (ed && ed.edition) bits.push(`ed. ${ed.edition}${ed.update && ed.update !== "0" ? `/${ed.update}` : ""}`); + if (ed && ed.issueDate) bits.push(`issued ${fmtIssue(ymd(ed.issueDate))}`); + meta = [bits.join(" · "), "outlined area below is the coverage"].filter(Boolean).join(" — "); } else { // ienc title = `${pk.title} River`; sub = `USACE Inland ENC · ${pk.cells.length} chart${pk.cells.length > 1 ? "s" : ""}`; meta = "outlined area below is the coverage"; } - // User packs have no coverage map; everything else shows the preview. - const previewMap = pk.kind === "user" ? "" : previewMapHost(); + // Every pack now shows the coverage preview (user packs get per-cell bboxes + // from the server metadata). + const previewMap = previewMapHost(); // Per-cell show/hide list, only for an installed & active pack (a fully // disabled pack is already hidden, so per-cell control is moot there). let extra = ""; @@ -569,6 +594,13 @@ export class ChartLibrary extends HTMLElement { // { name, title, shown }. Cells come from the pack's catalogue membership // intersected with what's actually installed; titles from the NOAA catalogue. _packCellItems(pk) { + // User packs: the per-cell list comes from the server detail (its own titles + + // compilation scale), already exactly the baked set — no catalogue intersect. + if (pk.kind === "user") { + const cells = (this._packDetail.get(pk.key) || {}).cells || []; + return cells.map((c) => ({ name: c.name, title: c.title || "", scale: c.scale || 0, shown: !this._hiddenCells.has(c.name) })) + .sort((a, b) => a.name.localeCompare(b.name)); + } let names = []; if (pk.kind === "noaa") names = this._districtCellNames(pk.cg) || []; else if (pk.cells) names = pk.cells.map((c) => c.name); @@ -611,14 +643,25 @@ export class ChartLibrary extends HTMLElement { return { fc: { type: "FeatureCollection", features: feats }, bounds: feats.length && w <= e ? [w, s, e, n] : null }; } - // Coverage {fc, bounds} for any pack: NOAA cells (catalog bb) or IENC cells. + // Coverage {fc, bounds} for any pack: NOAA cells (catalog bb), IENC cells, or a + // user pack (per-cell bboxes from the server detail, else the pack's union bbox). _packCoverage(pk) { if (!pk) return { fc: { type: "FeatureCollection", features: [] }, bounds: null }; if (pk.kind === "noaa") return this._districtCoverage(pk.cg); + const box = (w, s, e, n) => ({ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]] } }); const feats = []; + if (pk.kind === "user") { + const cells = (this._packDetail.get(pk.key) || {}).cells || []; + for (const c of cells) { + const [w, s, e, n] = c.bbox || []; + if ([w, s, e, n].every(Number.isFinite)) feats.push(box(w, s, e, n)); + } + if (!feats.length && Array.isArray(pk.bbox) && pk.bbox.every(Number.isFinite)) feats.push(box(...pk.bbox)); + return { fc: { type: "FeatureCollection", features: feats }, bounds: pk.bbox || null }; + } for (const c of pk.cells || []) { const [w, s, e, n] = c.bbox || []; - if ([w, s, e, n].every(Number.isFinite)) feats.push({ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[[w, s], [e, s], [e, n], [w, n], [w, s]]] } }); + if ([w, s, e, n].every(Number.isFinite)) feats.push(box(w, s, e, n)); } return { fc: { type: "FeatureCollection", features: feats }, bounds: pk.bbox || null }; } @@ -644,6 +687,19 @@ export class ChartLibrary extends HTMLElement { this.shadowRoot.querySelectorAll(".m-row[data-pack]").forEach((el) => el.classList.toggle("sel", el.dataset.pack === key)); this._updateDetail(); this._setPhoneLevel("detail"); // phone: advance packs → detail + this._ensurePackDetail(key); // user packs: lazy-load per-cell detail, then re-render + } + + // Fetch a user pack's per-cell detail (GET /api/pack/) once and cache it, + // re-rendering the detail pane when it arrives. NOAA/IENC packs derive their cell + // list from their catalogue, so they're skipped. + async _ensurePackDetail(key) { + if (!key || !this._api || this._packDetail.has(key)) return; + if (/^(noaa-d\d+|ienc-)/.test(key)) return; + const detail = await this._api.packDetail(key); + if (!detail) return; + this._packDetail.set(key, detail); + if (this._active && this._selPack === key) this._updateDetail(); } // Rebuild only the detail column (+ its buttons + preview map), leaving the list @@ -963,32 +1019,37 @@ export class ChartLibrary extends HTMLElement { return !!(host && host.querySelector("#agree")); } - // -- User-Charts local-file import (drop a .zip / .000 / .pmtiles) --------- - // .zip → list its cells for selection; .000 → store + bake; .pmtiles → handed to - // the shell (it owns the client-side plotter archive path). After a store/bake - // we dispatch charts-changed so the shell reconciles the map. + // -- User-Charts import (drop a .zip / .000 / .pmtiles) -------------------- + // .zip → upload the whole exchange set; the server parses CATALOG.031, names the + // pack from its identity, bakes it, and writes the metadata sidecar (one pack per + // upload). .000 → upload the lone cell + auto-bake. .pmtiles → handed to the shell + // (its client-side plotter archive path). After each, charts-changed lets the + // shell reconcile the map. async openFiles(fileList) { const log = this.shadowRoot.getElementById("import-log"); - const rawInstalled = []; for (const file of fileList) { const lower = file.name.toLowerCase(); try { if (lower.endsWith(".zip")) { - const cells = cellEntries(await readCentralDirectory(file)); - let added = 0; - for (const rec of cells) { - this._archive.set(rec.name, { blob: file, entry: rec.base, updates: rec.updateCount }); - this._selected.add(rec.name); - added++; - } - if (log) log.textContent = `${file.name}: ${added} cell(s) found`; + const t = this._notify ? this._notify.task("import:zip", { label: `Importing ${file.name}…` }) : null; + try { + const { set } = await this._api.importZipAndWait(file, { name: file.name.replace(/\.zip$/i, ""), onStatus: this._jobStatus(t) }); + if (t) t.done(); + if (log) log.textContent = `imported ${file.name}`; + this._selProvider = "user"; this._selPack = set; // reveal the new pack + } catch (e) { if (t) t.fail(e); throw e; } } else if (lower.endsWith(".000")) { - // Raw cell: persist it; it gets baked into the archive below. + // Lone base cell: upload to the server cache, then bake an auto-named pack. const name = file.name.replace(/\.000$/i, ""); - await this._store.put(name, new Uint8Array(await file.arrayBuffer())); - this._installed.add(name); - rawInstalled.push(name); - if (log) log.textContent = `imported ${name}`; + const t = this._notify ? this._notify.task("import:cell", { label: `Importing ${name}…` }) : null; + try { + await this._api.uploadCell(name, new Uint8Array(await file.arrayBuffer())); + const { job, set } = await this._api.importCells("auto", [name]); + await this._api.pollJob(job, { name, onStatus: this._jobStatus(t) }); + if (t) t.done(); + if (log) log.textContent = `imported ${name}`; + this._selProvider = "user"; this._selPack = set; + } catch (e) { if (t) t.fail(e); throw e; } } else if (lower.endsWith(".pmtiles")) { // A prebaked archive — the plotter is shell-owned, so hand the file to the // shell's client-side archive path (addArchive + persist). @@ -1002,103 +1063,9 @@ export class ChartLibrary extends HTMLElement { if (log) log.textContent = `${file.name}: ${err.message}`; } } - this.renderArchiveList(); - // Re-bake the now-larger stored cell set on the server. - await this._refreshCharts(); - } - - // Bake the LOCALLY-imported cells (the OPFS store) into the "import" set: upload - // each cell to the server, then kick the import job. On completion dispatch - // charts-changed (the shell reconciles the map). Coalesces concurrent rebakes. - async _refreshCharts() { - if (!this._store || !this._api) return; - if (this._charting) { this._chartingAgain = true; return; } - this._charting = true; - let t = null; - try { - const local = await this._store.list().catch(() => []); - if (local.length) { - t = this._notify ? this._notify.task("import:user", { label: "Preparing your charts…" }) : null; - for (const name of local) { - try { - const bytes = await this._store.getBytes(name); - if (bytes && bytes.length) await this._api.uploadCell(name, bytes); - } catch (e) { console.warn("[charts] upload", name, e); } - } - const { job } = await this._api.importCells("import", local); - await this._api.pollJob(job, { name: "your", onStatus: this._jobStatus(t) }); - if (t) t.done(); - } - await this._syncRegistry(); - this._changed(); - } catch (e) { - console.warn("[charts] import bake", e); - if (t) t.fail(e); - } finally { - this._charting = false; - if (this._chartingAgain) { this._chartingAgain = false; this._refreshCharts(); } - } - } - - // Bake the selected archive cells into the "import" set: extract each from its zip, - // store it, then bake (via _refreshCharts). Mirrors the old shell importSelected. - async importSelected() { - const names = [...this._selected].filter((n) => this._archive.has(n)); - if (!names.length) return; - const imported = []; - let done = 0; - const t = this._notify ? this._notify.task("import:archive", { label: "Importing charts" }) : null; - for (const name of names) { - if (t) t.progress(done / names.length, `${name} · ${done + 1} of ${names.length}`); - try { - const { blob, entry } = this._archive.get(name); - const bytes = await extractEntry(blob, entry); - await this._store.put(name, bytes); // persist only - this._installed.add(name); - this._archive.delete(name); - this._selected.delete(name); - imported.push(name); - } catch (err) { - console.error("[import]", name, err); - if (t) t.progress(done / names.length, `${name}: ${err.message}`); - } - done++; - } - if (t) t.done(); - this.renderArchiveList(); - // New cells stored → bake them on the server. - if (imported.length) await this._refreshCharts(); - } - - // Re-bake every installed cell into the server "user" set and render it (the - // bake runs server-side; see _refreshCharts). - async rebakeArchive() { - const names = [...this._installed]; - if (!names.length) return; - const t = this._notify ? this._notify.task("rebake:user", { label: "Baking charts…" }) : null; - if (t) t.progress(null, `${names.length} chart${names.length > 1 ? "s" : ""}`); - try { await this._refreshCharts(); if (t) t.done(); } - catch (e) { console.error("[bake]", e); if (t) t.fail(e); } - } - - // The "from archive" selectable cell list (after a .zip is opened). - renderArchiveList() { - const el = this.shadowRoot.getElementById("archive-list"); - if (!el) return; - const names = [...this._archive.keys()].sort(); - if (!names.length) { el.innerHTML = ""; return; } - const nSel = [...this._selected].filter((n) => this._archive.has(n)).length; - const items = names.map((name) => ({ name, label: this._byName.get(name)?.l || "", checked: this._selected.has(name) })); - el.innerHTML = archiveList({ items, nSel }); - el.querySelectorAll("input[type=checkbox]").forEach((cb) => (cb.onchange = () => this.toggleSelect(cb.dataset.name))); - const ib = this.shadowRoot.getElementById("import-btn"); - if (ib) ib.onclick = () => this.importSelected(); - } - - toggleSelect(name) { - if (this._selected.has(name)) this._selected.delete(name); - else this._selected.add(name); - this.renderArchiveList(); + await this._syncRegistry(); + this._changed(); + if (this._active) this.render(); } // Wire the file-import controls (the drop zone is re-rendered, so bound each render). diff --git a/web/src/plugins/chart-library.view.mjs b/web/src/plugins/chart-library.view.mjs index b08a14c..560364f 100644 --- a/web/src/plugins/chart-library.view.mjs +++ b/web/src/plugins/chart-library.view.mjs @@ -167,11 +167,10 @@ export const STYLE = ` // picker. Wired by _wireImport via #file/#drop/#pick. export function widgetBody() { return ` -

Add your own charts — drop a NOAA .zip / .000, or a baked .pmtiles. They're baked right here in your browser and kept offline alongside the prebaked charts.

+

Add your own charts — drop a NOAA/IENC exchange-set .zip, an individual .000, or a baked .pmtiles. A .zip becomes its own pack, named and described from its catalogue.

Drop a .zip, .000 or .pmtiles here, or
-
-
`; +
`; } // The full (non-widget) Library body: search box + 3-pane miller + freshness @@ -340,7 +339,6 @@ export function importDetail() {
Drop a .zip, .000 or .pmtiles here, or
-
`; } @@ -374,14 +372,3 @@ export function agreementModal({ encUrl, agreementUrl }) { `; } - -// The "from archive" selectable cell list (after a .zip is opened). -// items: [{ name, label, checked }] nSel: count of selected -export function archiveList({ items, nSel }) { - return `

From archive (${items.length})

` + items.map((it) => { - const checked = it.checked ? "checked" : ""; - return ``; - }).join("") + - `
`; -} From 167feafac54c2d9261c73e56c0a6fdcbf94aebaf Mon Sep 17 00:00:00 2001 From: Jeremy Collins Date: Thu, 25 Jun 2026 22:58:52 -0400 Subject: [PATCH 5/5] fix(catalog): don't depend on the NOAA master index for upload titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CATALOG.031 is often absent or omits titles in real uploads, but the NOAA master index (catalog.json) is a large, separate artifact that must not become a dependency of the server binary or this feature. Title resolution is now purely upload-local: CATALOG.031 LFIL when the exchange set provides it, otherwise no human title — ExtractCellMeta leaves Title empty (S-57 headers carry only the cell code), and consumers fall back to the cell Name. All other metadata (scale/edition/date/agency/ coverage) still comes from each cell's own header, so a catalogue-less upload is fully described regardless. Adds a no-CATALOG test. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/engine/baker/meta.go | 8 ++-- internal/engine/server/import_meta_test.go | 43 ++++++++++++++++++++++ internal/engine/server/setmeta.go | 12 ++++-- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/internal/engine/baker/meta.go b/internal/engine/baker/meta.go index a07bb83..bff8d7c 100644 --- a/internal/engine/baker/meta.go +++ b/internal/engine/baker/meta.go @@ -13,7 +13,7 @@ import ( // no human chart name. type CellMeta struct { Name string `json:"name"` // cell stem, e.g. "US5MD1MC" - Title string `json:"title,omitempty"` // long name (from CATALOG.031), else dataset name + Title string `json:"title,omitempty"` // human chart name (from CATALOG.031 LFIL); empty if none — consumers show Name Scale int `json:"scale,omitempty"` // compilation scale denominator (CSCL) Edition string `json:"edition,omitempty"` Update string `json:"update,omitempty"` @@ -25,8 +25,9 @@ type CellMeta struct { // ExtractCellMeta parses each cell's header + coverage (coverage-only, cheap) and // returns per-cell metadata keyed by cell stem. Cells that fail to parse are -// reported via onSkip and omitted. Title is populated with the dataset name as a -// fallback; the caller overlays the catalogue long name where available. +// reported via onSkip and omitted. Title is left empty (S-57 headers carry no human +// chart name — only the cell code); the caller overlays the CATALOG.031 long name +// where the exchange set provides one. func ExtractCellMeta(cells map[string]CellData, onSkip func(name string, err error)) map[string]CellMeta { out := make(map[string]CellMeta, len(cells)) names := make([]string, 0, len(cells)) @@ -49,7 +50,6 @@ func ExtractCellMeta(cells map[string]CellData, onSkip func(name string, err err } m := CellMeta{ Name: stem, - Title: chart.DatasetName(), Scale: int(chart.CompilationScale()), Edition: chart.Edition(), Update: chart.UpdateNumber(), diff --git a/internal/engine/server/import_meta_test.go b/internal/engine/server/import_meta_test.go index 3a8467b..ced80c8 100644 --- a/internal/engine/server/import_meta_test.go +++ b/internal/engine/server/import_meta_test.go @@ -44,6 +44,49 @@ func buildExchangeZip(t *testing.T) []byte { return buf.Bytes() } +// TestImport_NoCatalog covers the common real-world case where an upload has NO +// CATALOG.031 (producers don't always include it): naming + full metadata still +// come from the cells' own headers — no dependency on any master index. The human +// title falls back to the cell's dataset name; the client resolves a nicer name +// where it can. +func TestImport_NoCatalog(t *testing.T) { + s := New(t.TempDir(), t.TempDir(), t.TempDir(), false) + cell, err := os.ReadFile("../../../testdata/US5MD1MC.000") + if err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + f, _ := zw.Create("ENC_ROOT/US5MD1MC/US5MD1MC.000") // cell only — no CATALOG.031 + if _, err := f.Write(cell); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + + cells, _, cat, err := extractZipCells(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if cat != nil { + t.Fatal("expected no catalogue (none in the zip)") + } + // Naming still works (cell-prefix fallback), and metadata comes from the header. + if set := s.deriveUploadSet(cat, cells); set != "user-us5md1mc" { + t.Errorf("deriveUploadSet = %q, want user-us5md1mc", set) + } + meta := buildSetMeta("user-us5md1mc", baker.ExtractCellMeta(cells, nil), cat) + if meta.ScaleMin != 12000 || len(meta.BBox) != 4 || meta.Agency != "NOAA (US)" { + t.Errorf("header metadata missing: scale=%d bbox=%v agency=%q", meta.ScaleMin, meta.BBox, meta.Agency) + } + // No catalogue → no human title (no master-index lookup); the cell Name carries + // the identity, which the client shows. + if len(meta.Cells) != 1 || meta.Cells[0].Name != "US5MD1MC" || meta.Cells[0].Title != "" { + t.Errorf("per-cell = %+v; want Name US5MD1MC, empty Title", meta.Cells[0]) + } +} + // TestImport_AutoNameAndMeta exercises the upload metadata wiring (minus HTTP and // the bake): extract → derive a CATALOG-identity pack name → extract per-cell // metadata → write the sidecar → surface it on /api/packs and /api/pack/. diff --git a/internal/engine/server/setmeta.go b/internal/engine/server/setmeta.go index 3f890e0..a711052 100644 --- a/internal/engine/server/setmeta.go +++ b/internal/engine/server/setmeta.go @@ -114,8 +114,12 @@ func slug(s string) string { } // buildSetMeta assembles a pack's SetMeta from the per-cell header metadata and -// (optionally) the exchange-set catalogue. The catalogue supplies human chart -// titles (LFIL) and a coverage bbox even for cells whose header lacks M_COVR. +// (optionally) the exchange-set catalogue. The catalogue's LFIL supplies the human +// chart title and fills a coverage bbox for cells whose header lacked M_COVR. When +// there's no CATALOG.031 (common — producers don't always include it), per-cell +// titles are left empty and the CLIENT resolves them from the NOAA master index it +// already holds (chart-library's _byName); cells stay fully described by their own +// header (scale/edition/date/agency/coverage) regardless. func buildSetMeta(set string, cellMeta map[string]baker.CellMeta, cat *s57.Catalog) SetMeta { // Catalogue overlay: stem → long name, stem → bbox. catTitle := map[string]string{} @@ -143,8 +147,10 @@ func buildSetMeta(set string, cellMeta map[string]baker.CellMeta, cat *s57.Catal sort.Strings(stems) for _, stem := range stems { c := cellMeta[stem] + // Title from the exchange-set catalogue (LFIL) when present; otherwise left + // empty for the client to resolve from the NOAA master index. if t := catTitle[stem]; t != "" { - c.Title = t // prefer the catalogue's human name over the dataset name + c.Title = t } if !c.HasBBox { if box, ok := catBox[stem]; ok {