File size: 14,214 Bytes
9f21d0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
#!/usr/bin/env node
// Merge the committed core config with every data/custom/*/satvis.yaml plugin
// config, inline each group's extraRecordsFile TLE text into extraRecords,
// validate, and write worker/src/config/satvis.generated.json.
//
// A config contributes two independent sections: `groups` (what is served, as
// which unit) and `satellites` (static per-satellite facts, keyed by NORAD id
// and attached to records at refresh time). With no plugin configs present this
// still produces a valid generated file from the core config alone (so lint/CI
// stay green).

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

import YAML from "yaml";

const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const workerDir = path.resolve(scriptDir, "..");
const repoRoot = path.resolve(workerDir, "..");

const coreConfigPath = path.join(workerDir, "src", "config", "satvis.core.yaml");
const customDir = path.join(repoRoot, "data", "custom");
const outPath = path.join(workerDir, "src", "config", "satvis.generated.json");

const PLUGIN_CONFIG_NAME = "satvis.yaml";
// Pre-YAML plugin config name. Detected only to fail loudly: silently skipping
// it would make a plugin's groups vanish from the build without a word.
const LEGACY_PLUGIN_CONFIG_NAME = "groups.json";

const GROUP_NAME_RE = /^[a-zA-Z0-9_-]+$/;

function readYaml(file) {
  return YAML.parse(fs.readFileSync(file, "utf8"));
}

// Parse TLE text into TleRecord objects. Supports 3-line blocks (optional
// leading "0 " on the name line), and bare 2-line blocks (no name).
//
// Deliberate near-duplicate of src/modules/util/gp.ts parseTleText with an
// intentionally opposite error policy: this build tool fails loud (throws) so
// bad input never ships, while the browser path warns and skips. Do not
// "unify" them.
function parseTleText(text) {
  const lines = text
    .split(/\r?\n/)
    .map((line) => line.replace(/\s+$/, ""))
    .filter((line) => line.length > 0);
  const records = [];
  let i = 0;
  while (i < lines.length) {
    const line = lines[i];
    if (line.startsWith("1 ") && i + 1 < lines.length && lines[i + 1].startsWith("2 ")) {
      // Bare 2-line block.
      records.push({ TLE_LINE1: line, TLE_LINE2: lines[i + 1] });
      i += 2;
    } else if (i + 2 < lines.length && lines[i + 1].startsWith("1 ") && lines[i + 2].startsWith("2 ")) {
      // 3-line block; strip an optional "0 " name prefix.
      const name = line.startsWith("0 ") ? line.slice(2) : line;
      records.push({ OBJECT_NAME: name, TLE_LINE1: lines[i + 1], TLE_LINE2: lines[i + 2] });
      i += 3;
    } else {
      throw new Error(`unrecognized TLE block at line ${i + 1}: ${JSON.stringify(line)}`);
    }
  }
  return records;
}

// Load and normalize one config file. `extraRecordsFile` (generator-only) is
// resolved relative to the config's directory and inlined into extraRecords.
function loadConfig(configPath) {
  const config = readYaml(configPath);
  const dir = path.dirname(configPath);
  const groups = (config.groups ?? []).map((group) => {
    const { extraRecordsFile, ...rest } = group;
    if (extraRecordsFile) {
      const txtPath = path.join(dir, extraRecordsFile);
      const parsed = parseTleText(fs.readFileSync(txtPath, "utf8"));
      rest.extraRecords = [...(rest.extraRecords ?? []), ...parsed];
    }
    return rest;
  });
  return { groups, satellites: config.satellites ?? [] };
}

function discoverPluginConfigs() {
  if (!fs.existsSync(customDir)) {
    return [];
  }
  const configs = [];
  for (const entry of fs.readdirSync(customDir, { withFileTypes: true }).toSorted((a, b) => a.name.localeCompare(b.name))) {
    if (!entry.isDirectory()) {
      continue;
    }
    const dir = path.join(customDir, entry.name);
    const candidate = path.join(dir, PLUGIN_CONFIG_NAME);
    if (fs.existsSync(candidate)) {
      configs.push(candidate);
      continue;
    }
    // Fail loudly rather than silently dropping a plugin that has not migrated.
    if (fs.existsSync(path.join(dir, LEGACY_PLUGIN_CONFIG_NAME))) {
      throw new Error(
        `${path.relative(repoRoot, path.join(dir, LEGACY_PLUGIN_CONFIG_NAME))} is the pre-YAML config format; ` +
          `rename it to ${PLUGIN_CONFIG_NAME} and convert it to YAML (see worker/src/config/satvis.core.yaml)`,
      );
    }
  }
  return configs;
}

// Validate a group's `satellites` rows (if any). Each row must select by
// `noradId` (number) or `upstreamName` (string); `name`/`metadata` optional
// with checked types. Throws with the group name on any violation.
function validateSatellites(group) {
  if (group.satellites === undefined) {
    return;
  }
  if (!Array.isArray(group.satellites)) {
    throw new Error(`group ${JSON.stringify(group.name)}: "satellites" must be an array`);
  }
  group.satellites.forEach((row, i) => {
    const where = `group ${JSON.stringify(group.name)} satellites[${i}]`;
    if (row === null || typeof row !== "object" || Array.isArray(row)) {
      throw new Error(`${where}: must be an object`);
    }
    if (row.noradId !== undefined && typeof row.noradId !== "number") {
      throw new Error(`${where}: "noradId" must be a number`);
    }
    if (row.upstreamName !== undefined && typeof row.upstreamName !== "string") {
      throw new Error(`${where}: "upstreamName" must be a string`);
    }
    if (row.noradId === undefined && row.upstreamName === undefined) {
      throw new Error(`${where}: must have a "noradId" or "upstreamName"`);
    }
    if (row.name !== undefined && typeof row.name !== "string") {
      throw new Error(`${where}: "name" must be a string`);
    }
    if (row.metadata !== undefined) {
      validateMetadata(row.metadata, where);
      if (row.noradId === undefined) {
        // Metadata is keyed by NORAD id in the merged table; a name-only row has
        // no key to lift it under.
        throw new Error(`${where}: "metadata" requires a "noradId" (matching is by NORAD id only)`);
      }
      // Same rule as a top-level table entry: an empty bag would attach a
      // meaningless `metadata` key to the served record, which is supposed to be
      // present only when the satellite actually has facts to carry.
      if (Object.keys(row.metadata).length === 0) {
        throw new Error(`${where}: "metadata" is empty — remove it or give it a field`);
      }
    }
    if (row.decayed !== undefined && typeof row.decayed !== "boolean") {
      throw new Error(`${where}: "decayed" must be a boolean`);
    }
  });
}

// Validate a metadata bag. The worker treats it as opaque, so only the fields
// whose shape the frontend depends on are checked here: the swath extents, which
// must be positive numbers and must be given for both sides or neither (see
// swathExtentsOf in src/config/satelliteMetadata.ts, which reads them as a pair
// and treats a half-specified swath as absent).
function validateMetadata(metadata, where) {
  if (metadata === null || typeof metadata !== "object" || Array.isArray(metadata)) {
    throw new Error(`${where}: "metadata" must be an object`);
  }
  const sides = ["swathStarboardKm", "swathPortKm"];
  for (const side of sides) {
    const value = metadata[side];
    if (value !== undefined && (typeof value !== "number" || !Number.isFinite(value) || value <= 0)) {
      throw new Error(`${where}: "${side}" must be a positive number`);
    }
  }
  const given = sides.filter((side) => metadata[side] !== undefined);
  if (given.length === 1) {
    throw new Error(`${where}: swath needs both "swathStarboardKm" and "swathPortKm" (got only ${JSON.stringify(given[0])})`);
  }
}

// The keys of a satellite-table entry that are bookkeeping rather than payload.
// Everything else in the entry IS the metadata bag, which is what keeps adding a
// field a data-only edit.
const TABLE_ENTRY_KEYS = new Set(["noradId", "name", "decayed"]);

function metadataFields(entry) {
  return Object.fromEntries(Object.entries(entry).filter(([key]) => !TABLE_ENTRY_KEYS.has(key)));
}

// Validate a config's top-level `satellites` table. Every entry keys on a
// numeric `noradId`; `name` is documentation only.
function validateSatelliteTable(entries, source) {
  if (!Array.isArray(entries)) {
    throw new Error(`${source}: "satellites" must be an array`);
  }
  entries.forEach((entry, i) => {
    const where = `${source} satellites[${i}]`;
    if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
      throw new Error(`${where}: must be an object`);
    }
    if (typeof entry.noradId !== "number") {
      throw new Error(`${where}: "noradId" must be a number`);
    }
    if (entry.name !== undefined && typeof entry.name !== "string") {
      throw new Error(`${where}: "name" must be a string`);
    }
    if (entry.decayed !== undefined && typeof entry.decayed !== "boolean") {
      throw new Error(`${where}: "decayed" must be a boolean`);
    }
    const metadata = metadataFields(entry);
    validateMetadata(metadata, where);
    if (Object.keys(metadata).length === 0) {
      throw new Error(`${where}: has no metadata fields — remove the entry or give it something to attach`);
    }
  });
}

function validate(groups) {
  const names = new Set();
  for (const group of groups) {
    if (typeof group.name !== "string" || !GROUP_NAME_RE.test(group.name)) {
      throw new Error(`invalid group name ${JSON.stringify(group.name)} (must match ${GROUP_NAME_RE})`);
    }
    if (names.has(group.name)) {
      throw new Error(`duplicate group name ${JSON.stringify(group.name)}`);
    }
    names.add(group.name);
    validateSatellites(group);
  }
  // include targets must exist.
  for (const group of groups) {
    for (const dep of group.include ?? []) {
      if (!names.has(dep)) {
        throw new Error(`group ${JSON.stringify(group.name)} includes unknown group ${JSON.stringify(dep)}`);
      }
    }
  }
  // no include cycles (DFS).
  const byName = new Map(groups.map((g) => [g.name, g]));
  const state = new Map();
  const visit = (name, stack) => {
    if (state.get(name) === "done") {
      return;
    }
    if (state.get(name) === "visiting") {
      throw new Error(`include cycle detected: ${[...stack, name].join(" -> ")}`);
    }
    state.set(name, "visiting");
    for (const dep of byName.get(name)?.include ?? []) {
      visit(dep, [...stack, name]);
    }
    state.set(name, "done");
  };
  for (const group of groups) {
    visit(group.name, []);
  }
}

// Accumulator for the merged satellite table, keyed by NORAD id. Contributions
// arrive from two kinds of place — a config's top-level `satellites` table and a
// group's `satellites[].metadata` rows — and are merged field-wise in arrival
// order, so a later, more specific contribution wins per field.
//
// Conflicts (two places giving one field different values for one satellite) are
// a config bug: whichever won would depend on file discovery order, so we fail
// with both origins instead of picking. Identical values merge silently, which
// is what makes it safe to repeat a satellite across groups.
function createSatelliteTable() {
  const byNoradId = new Map();
  return {
    add(noradId, fields, origin) {
      const existing = byNoradId.get(noradId);
      if (existing === undefined) {
        byNoradId.set(noradId, { noradId, metadata: { ...fields.metadata }, origins: [origin], name: fields.name, decayed: fields.decayed });
        return;
      }
      for (const [key, value] of Object.entries(fields.metadata)) {
        const previous = existing.metadata[key];
        if (previous !== undefined && previous !== value) {
          throw new Error(
            `conflicting metadata for noradId ${noradId}: ${origin} sets ${key}=${JSON.stringify(value)}, ` +
              `but ${existing.origins.join(", ")} set ${key}=${JSON.stringify(previous)}`,
          );
        }
        existing.metadata[key] = value;
      }
      existing.name ??= fields.name;
      existing.decayed ||= fields.decayed;
      existing.origins.push(origin);
    },
    // Strip the bookkeeping (`origins`) that only the merge needed, and drop
    // absent optional keys so the generated JSON stays free of nulls.
    entries() {
      const out = [];
      for (const { noradId, name, decayed, metadata } of byNoradId.values()) {
        const entry = { noradId };
        if (name !== undefined) {
          entry.name = name;
        }
        if (decayed) {
          entry.decayed = true;
        }
        entry.metadata = metadata;
        out.push(entry);
      }
      return out.toSorted((a, b) => a.noradId - b.noradId);
    },
  };
}

function main() {
  const configs = [
    { path: coreConfigPath, config: loadConfig(coreConfigPath) },
    ...discoverPluginConfigs().map((configPath) => ({ path: configPath, config: loadConfig(configPath) })),
  ];

  const groups = configs.flatMap(({ config }) => config.groups);
  validate(groups);

  const table = createSatelliteTable();
  for (const { path: configPath, config } of configs) {
    const source = path.relative(repoRoot, configPath);
    validateSatelliteTable(config.satellites, source);
    for (const entry of config.satellites) {
      table.add(entry.noradId, { metadata: metadataFields(entry), name: entry.name, decayed: entry.decayed }, `${source} satellites`);
    }
  }
  // Group rows contribute after every table. Order only decides which origin is
  // named first in a conflict message — it does NOT establish precedence: two
  // places giving one satellite different values for a field is a build failure,
  // because whichever won would depend on config discovery order.
  for (const group of groups) {
    for (const row of group.satellites ?? []) {
      if (row.metadata !== undefined) {
        table.add(row.noradId, { metadata: row.metadata, name: row.name, decayed: row.decayed }, `group ${JSON.stringify(group.name)}`);
      }
    }
  }
  const satellites = table.entries();

  const generated = { groups, satellites };
  fs.writeFileSync(outPath, `${JSON.stringify(generated, null, 2)}\n`);
  process.stdout.write(`Wrote ${path.relative(repoRoot, outPath)} (${groups.length} groups, ${satellites.length} satellites)\n`);
}

main();