#!/usr/bin/env node
// Harbour Almanac's fetcher (contract 1): the kitchen-sink example of
// kanboombo.com/developers/plugins/examples/harbour-almanac/. The harbour and
// its data are invented; everything else is how a real plug-in is written.
//
// Kanboombo downloads the four files plugin.json declares and starts this
// with the run context on stdin. It reads them, and prints one record per
// line on stdout:
//
// tide a value per day: high and low water, in metres (observation dates)
// catch a value per month: tonnes landed, on the day it was published,
// with the month in `period`, and `revises` on a correction
// council the harbour council's meetings: scheduled, happened, cancelled;
// a meeting that moves keeps its id, so Kanboombo marks it moved
// notices notices to mariners: a dated title and an https link
//
// Exit 0 when the files read as expected, even with nothing new. Exit 2 with
// a reason on stderr when a file is not what this plug-in knows (a renamed
// column, a status it has never seen): the run fails and nothing is written,
// which is better than writing something wrong.
//
// MIT-0 (LICENSE beside it): copy any of it.
import { readFileSync } from "node:fs";
import { CsvError, readTable } from "./csv.mjs";
function fail(reason) {
process.stderr.write(`harbour-almanac: ${reason}\n`);
process.exit(2);
}
let context;
try {
context = JSON.parse(readFileSync(0, "utf8"));
} catch (error) {
fail(`the run context is not JSON (${error.message})`);
}
// `today` is the date in the manifest's time zone (Pacific/Auckland), worked
// out by Kanboombo. Never use new Date().toISOString() for it: at 08:30 in
// Auckland it is still yesterday in UTC.
const today = context.today;
const DATE = /^\d{4}-\d{2}-\d{2}$/;
if (!DATE.test(today ?? "")) fail("the run context has no today");
const addDays = (date, days) => new Date(Date.parse(`${date}T00:00:00Z`) + days * 86_400_000).toISOString().slice(0, 10);
/**
* The first date worth printing for a series. A first run fills what the
* manifest's backfill allows (`backfillFrom`; null means everything). After
* that, at least the series' snapshot window, and from its newest stored
* record when that is older, so days missed while runs failed are filled.
*/
function since(series, days) {
if (context.backfill) return context.backfillFrom ?? "0000-01-01";
const window = addDays(today, -days);
const last = context.series?.[series]?.last ?? null;
return last !== null && last < window ? last : window;
}
/** A declared source's text, from the path the run context gives. Never build the path yourself. */
function source(name) {
const file = context.sources?.[name];
if (!file) fail(`the run context names no source called ${name}`);
try {
return readFileSync(file, "utf8");
} catch (error) {
fail(`cannot read the source ${name} (${error.message})`);
}
}
function json(name, label) {
try {
return JSON.parse(source(name));
} catch (error) {
fail(`${label} is not JSON (${error.message})`);
}
}
function table(name, label, columns) {
try {
return readTable(source(name), label, columns);
} catch (error) {
if (error instanceof CsvError) fail(error.message);
throw error;
}
}
function number(text, where) {
const value = Number(text);
if (text === "" || !Number.isFinite(value)) fail(`${where}: "${text}" is not a number`);
return value;
}
const print = (record) => process.stdout.write(`${JSON.stringify(record)}\n`);
const counts = { tide: 0, catch: 0, council: 0, notices: 0 };
// ── tide: a value series, dated by observation ────────────────────────────
// The table holds the whole year, predictions included. A prediction is not
// an observation: anything dated after today would refuse the whole batch.
{
const from = since("tide", 30);
for (const [index, row] of table("tides", "tides.csv", ["date", "high", "low", "status"]).entries()) {
const where = `tides.csv line ${index + 2}`;
if (!DATE.test(row.date)) fail(`${where}: "${row.date}" is not a date`);
if (row.date > today || row.date < from || row.status === "predicted") continue;
print({ kind: "value", series: "tide", date: row.date, fields: { high: number(row.high, where), low: number(row.low, where) } });
counts.tide += 1;
}
}
// ── catch: a value series, dated by publication ───────────────────────────
// A month's figure is dated the day it was published, with the month it
// describes in `period`. A correction is a new record on its own day, with
// `revises` naming the day of the figure it corrects.
{
const from = since("catch", 120);
for (const [index, row] of table("catch", "catch.csv", ["published", "month", "landed_t", "revises"]).entries()) {
const where = `catch.csv line ${index + 2}`;
if (!DATE.test(row.published) || !/^\d{4}-\d{2}$/.test(row.month)) fail(`${where}: expected a date and a month`);
if (row.published > today || row.published < from) continue;
if (row.revises !== "" && !DATE.test(row.revises)) fail(`${where}: "${row.revises}" is not a date`);
print({
kind: "value",
series: "catch",
date: row.published,
period: row.month,
fields: { landed: number(row.landed_t, where) },
...(row.revises ? { revises: row.revises } : {}),
});
counts.catch += 1;
}
}
// ── council: an event series ──────────────────────────────────────────────
// Print every meeting in the snapshot window (90 days back, 180 ahead) every
// run: one missing from three runs in a row is withdrawn. A meeting that
// moves keeps its id on its new date, and Kanboombo marks the old day moved.
{
const data = json("council", "council.json");
if (!Array.isArray(data?.meetings)) fail("council.json has no meetings list");
const STATES = { scheduled: "scheduled", held: "happened", cancelled: "cancelled" };
const from = since("council", 90);
const until = addDays(today, 180);
for (const meeting of data.meetings) {
const state = STATES[meeting?.status];
if (!state) fail(`council.json: meeting ${meeting?.id} has a status this plug-in does not know, "${meeting?.status}"`);
if (typeof meeting.id !== "string" || !/^[a-z0-9-]{1,64}$/.test(meeting.id)) fail(`council.json: a meeting has a bad id, ${JSON.stringify(meeting.id)}`);
if (!DATE.test(meeting.date ?? "")) fail(`council.json: meeting ${meeting.id} has no date`);
if (meeting.date < from || meeting.date > until) continue;
// Only a scheduled event may be dated after today. A meeting held "in the
// future" is a broken file; one cancelled ahead of its day is left out
// until its day comes (withdrawn after three runs, back as cancelled then).
if (meeting.date > today && state === "happened") fail(`council.json: meeting ${meeting.id} was held on ${meeting.date}, after today`);
if (meeting.date > today && state === "cancelled") continue;
const title = (meeting.note ? `${meeting.title}: ${meeting.note}` : meeting.title ?? "Harbour council meeting").slice(0, 200);
print({
kind: "event",
series: "council",
id: meeting.id,
date: meeting.date,
state,
title,
...(state === "happened" && typeof meeting.berthFee === "number" ? { value: meeting.berthFee } : {}),
});
counts.council += 1;
}
}
// ── notices: an item series ───────────────────────────────────────────────
// Items are kept (the series has no snapshot), so a notice the harbour takes
// down stays on its day. Links are https, to a host plugin.json declares.
{
const data = json("notices", "the notices file");
if (!Array.isArray(data?.notices)) fail("the notices file has no notices list");
const from = since("notices", 31);
for (const notice of data.notices) {
if (!DATE.test(notice?.published ?? "") || typeof notice.id !== "string" || typeof notice.title !== "string") fail(`a notice is missing its date, id or title: ${JSON.stringify(notice)}`);
if (notice.published > today || notice.published < from) continue;
if (typeof notice.url !== "string" || !notice.url.startsWith("https://")) fail(`notice ${notice.id} has no https link`);
print({ kind: "item", series: "notices", date: notice.published, id: notice.id, title: notice.title.slice(0, 200), url: notice.url });
counts.notices += 1;
}
}
process.stderr.write(`harbour-almanac: ${counts.tide} tides, ${counts.catch} catch figures, ${counts.council} meetings, ${counts.notices} notices (today is ${today} in Auckland)\n`);