@heroiclands/package-build 23.12.5 → 23.12.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,10 +25,19 @@
25
25
  * `resolvedDates.events`, position for position with `data.events`, and the
26
26
  * sort keys are read from it rather than parsed again here.
27
27
  *
28
- * **`whenText` and `untilText` are the dates as a reader sees them**: printed
29
- * as the `dateformat` helper prints a date, in the calendar the owning note
30
- * names in `data.calendar`, else the one the world note names, else in the era
31
- * the date itself names. A date that does not resolve keeps its authored text.
28
+ * **`whenText` and `untilText` are the dates as a reader of the owning note
29
+ * sees them**: printed as the `dateformat` helper prints a date, in the
30
+ * calendar the owning note resolves to — see
31
+ * {@link module:engine/note-calendars} — else in the era the date itself
32
+ * names. A date that does not resolve keeps its authored text.
33
+ *
34
+ * **`whenIn` and `untilIn` are the same dates in every calendar**: a struct
35
+ * keyed by each calendar note's canonical Address, every calendar the corpus
36
+ * holds or any note resolves to, so a query reads a date in the calendar it
37
+ * chooses — `e.whenIn['thalorna-note-lore-commoncal']`. Every row carries every
38
+ * key; a date a calendar cannot print reads there as it would in `whenText`
39
+ * with no calendar, and an absent date is `NULL`. The event views select the
40
+ * key of the page's calendar.
32
41
  *
33
42
  * @module
34
43
  */
@@ -36,6 +45,7 @@
36
45
  import { ownDocumentSystem, renderAddress } from "./address.mjs";
37
46
  import { printEventDateIn } from "./event-fields.mjs";
38
47
  import { EVENT_ENTRY_KEYS } from "./note-event-terms.mjs";
48
+ import { calendarAddress } from "./reckoning-markers.mjs";
39
49
 
40
50
  /** The columns a row carries besides the event's own keys, before them. */
41
51
  const IDENTITY_COLUMNS = Object.freeze(["note", "address"]);
@@ -46,12 +56,16 @@ export const EVENT_SORT_COLUMNS = Object.freeze(["whenSort", "whenYear", "untilS
46
56
  /** The printed dates a row carries, after the sort keys. */
47
57
  export const EVENT_TEXT_COLUMNS = Object.freeze(["whenText", "untilText"]);
48
58
 
59
+ /** The dates a row carries in every calendar, after the printed ones. */
60
+ export const EVENT_CALENDAR_COLUMNS = Object.freeze(["whenIn", "untilIn"]);
61
+
49
62
  /** Every column of the `events` relation, in order. */
50
63
  export const EVENT_COLUMNS = Object.freeze([
51
64
  ...IDENTITY_COLUMNS,
52
65
  ...EVENT_ENTRY_KEYS,
53
66
  ...EVENT_SORT_COLUMNS,
54
67
  ...EVENT_TEXT_COLUMNS,
68
+ ...EVENT_CALENDAR_COLUMNS,
55
69
  ]);
56
70
 
57
71
  /** The keys whose authored value is a date, printed as the text it was written as. */
@@ -95,18 +109,35 @@ function axisKeys(date) {
95
109
  }
96
110
 
97
111
  /**
98
- * The calendar the world note names, which displays every date whose own note
99
- * names none.
112
+ * Every calendar a set of records can print an event's date in: each calendar
113
+ * note's canonical Address, and every calendar a note resolves to.
100
114
  *
101
- * @param {Record<string, any>[]} records - Index records.
102
- * @returns {string|undefined} The calendar's Address or shortcode.
115
+ * @param {Iterable<Record<string, any>>} records - Index records, every
116
+ * Address a string.
117
+ * @returns {string[]} The calendars, sorted.
103
118
  */
104
- function worldCalendar(records) {
119
+ export function eventCalendars(records) {
120
+ const calendars = new Set();
105
121
  for (const record of records) {
106
- if (record?.type === "place" && record.data?.year?.days && record.data.calendar)
107
- return String(record.data.calendar);
122
+ const resolved = calendarAddress(record?.resolvedCalendar);
123
+ if (resolved) calendars.add(resolved);
124
+ if (
125
+ record?.type === "lore" &&
126
+ String(record.subType ?? "").toLowerCase() === "calendar" &&
127
+ record.package &&
128
+ record.shortcode &&
129
+ !record.documents
130
+ )
131
+ calendars.add(
132
+ renderAddress({
133
+ package: String(record.package),
134
+ system: "note",
135
+ type: "lore",
136
+ shortcode: String(record.shortcode),
137
+ }),
138
+ );
108
139
  }
109
- return undefined;
140
+ return [...calendars].sort();
110
141
  }
111
142
 
112
143
  /**
@@ -115,12 +146,16 @@ function worldCalendar(records) {
115
146
  * @param {Iterable<Record<string, any>>} records - Index records, every
116
147
  * Address a string, already narrowed to the audience being published to.
117
148
  * @param {object} [dates] - The reckoning context the dates are printed in.
149
+ * @param {readonly string[]} [calendars] - The calendars `whenIn` and
150
+ * `untilIn` are keyed by: {@link eventCalendars} of every record of the
151
+ * corpus, whatever the audience, so every surface's relation has the same
152
+ * keys. Absent, those of `records`.
118
153
  * @returns {object[]} One row per event entry, in record then entry order.
119
154
  */
120
- export function eventRows(records, dates) {
155
+ export function eventRows(records, dates, calendars) {
121
156
  const rows = [];
122
157
  const all = [...records];
123
- const world = worldCalendar(all);
158
+ const keys = calendars ?? eventCalendars(all);
124
159
  for (const record of all) {
125
160
  const events = record?.data?.events;
126
161
  if (!Array.isArray(events) || !events.length) continue;
@@ -128,7 +163,7 @@ export function eventRows(records, dates) {
128
163
  if (!note) continue;
129
164
  const resolved =
130
165
  Array.isArray(record.resolvedDates?.events) ? record.resolvedDates.events : [];
131
- const calendar = record.data?.calendar ? String(record.data.calendar) : world;
166
+ const calendar = calendarAddress(record.resolvedCalendar);
132
167
  events.forEach((entry, position) => {
133
168
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) return;
134
169
  const row = {
@@ -149,10 +184,20 @@ export function eventRows(records, dates) {
149
184
  row.untilSort = until.sort;
150
185
  row.untilYear = until.year;
151
186
  for (const key of DATE_KEYS) {
187
+ const date = resolved[position]?.[key];
152
188
  row[`${key}Text`] =
153
- row[key] === null ?
154
- null
155
- : printEventDateIn(row[key], resolved[position]?.[key], dates, calendar);
189
+ row[key] === null ? null : printEventDateIn(row[key], date, dates, calendar);
190
+ row[`${key}In`] =
191
+ keys.length ?
192
+ Object.fromEntries(
193
+ keys.map((each) => [
194
+ each,
195
+ row[key] === null ?
196
+ null
197
+ : printEventDateIn(row[key], date, dates, each),
198
+ ]),
199
+ )
200
+ : null;
156
201
  }
157
202
  rows.push(row);
158
203
  });
@@ -41,6 +41,7 @@
41
41
 
42
42
  import { ownDocumentSystem, renderAddress } from "./address.mjs";
43
43
  import { authoredSection, sectionHeading } from "./generated-sections.mjs";
44
+ import { calendarAddress } from "./reckoning-markers.mjs";
44
45
 
45
46
  /** A literal SQL string. */
46
47
  const literal = (text) => `'${String(text).replace(/'/g, "''")}'`;
@@ -48,6 +49,16 @@ const literal = (text) => `'${String(text).replace(/'/g, "''")}'`;
48
49
  /** A list of SQL string literals, for an `IN (…)`. */
49
50
  const literals = (texts) => texts.map(literal).join(", ");
50
51
 
52
+ /**
53
+ * The `When` column of a view on a page whose dates print in `calendar`: the
54
+ * event's date in that calendar, or — on a page with no calendar — in its
55
+ * owning note's.
56
+ *
57
+ * @param {string} [calendar] - The page's calendar, a canonical Address.
58
+ * @returns {string} The column's expression.
59
+ */
60
+ const whenColumn = (calendar) => (calendar ? `e.whenIn[${literal(calendar)}]` : "e.whenText");
61
+
51
62
  /** The order every view sorts its rows in: by date, undated last. */
52
63
  const BY_DATE = "ORDER BY e.whenSort NULLS LAST, e.address";
53
64
 
@@ -65,7 +76,7 @@ const BY_DATE = "ORDER BY e.whenSort NULLS LAST, e.address";
65
76
  *
66
77
  * @type {ReadonlyArray<Readonly<{slug: string, heading: string,
67
78
  * applies: (record: object) => boolean,
68
- * query: (self: string[], shape: CorpusShape, columns?: {felt?: boolean}) => {fence: string, own?: string}|null,
79
+ * query: (self: string[], shape: CorpusShape, columns?: {felt?: boolean, calendar?: string}) => {fence: string, own?: string}|null,
69
80
  * owners?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>,
70
81
  * felt?: (db: object, selves: string[][], shape: CorpusShape) => Promise<Set<string>>}>>}
71
82
  */
@@ -87,13 +98,13 @@ export const EVENT_VIEWS = Object.freeze([
87
98
  (record.type === "lore" && record.subType === "culture"),
88
99
  owners: (db, selves, shape) =>
89
100
  namedOwners(db, selves, "SELECT unnest(who).ref AS ref FROM events", shape.who),
90
- query: (self, shape) => {
101
+ query: (self, shape, columns = {}) => {
91
102
  if (!shape.who) return null;
92
103
  const named = `list_filter(e.who, w -> w.ref IN (${literals(self)}))`;
93
104
  return {
94
105
  fence: [
95
106
  "SELECT e.note AS _ref,",
96
- ' e.whenText AS "When",',
107
+ ` ${whenColumn(columns.calendar)} AS "When",`,
97
108
  ' e.summary AS "What happened",',
98
109
  ` array_to_string(list_transform(${named}, w -> w.role), ', ') AS "Role"`,
99
110
  "FROM events e",
@@ -117,12 +128,12 @@ export const EVENT_VIEWS = Object.freeze([
117
128
  'SELECT account."by" AS ref FROM (SELECT unnest(accounts) AS account FROM events)',
118
129
  shape.accounts,
119
130
  ),
120
- query: (self, shape) => {
131
+ query: (self, shape, columns = {}) => {
121
132
  if (!shape.accounts) return null;
122
133
  return {
123
134
  fence: [
124
135
  "SELECT e.note AS _ref,",
125
- ' e.whenText AS "When",',
136
+ ` ${whenColumn(columns.calendar)} AS "When",`,
126
137
  ' e.summary AS "Event",',
127
138
  ' e.account.says AS "What they say",',
128
139
  ' e.account.agrees AS "Agrees"',
@@ -145,7 +156,7 @@ export const EVENT_VIEWS = Object.freeze([
145
156
  "FROM (SELECT unnest(follows) AS edge FROM events)",
146
157
  shape.follows,
147
158
  ),
148
- query: (self, shape) => {
159
+ query: (self, shape, columns = {}) => {
149
160
  if (!shape.follows) return null;
150
161
  const edges =
151
162
  `list_filter(e.follows, f -> split_part(f.event, '#', 1) ` +
@@ -153,7 +164,7 @@ export const EVENT_VIEWS = Object.freeze([
153
164
  return {
154
165
  fence: [
155
166
  "SELECT e.note AS _ref,",
156
- ' e.whenText AS "When",',
167
+ ` ${whenColumn(columns.calendar)} AS "When",`,
157
168
  ' e.summary AS "What followed",',
158
169
  ` array_to_string(list_transform(${edges}, f -> f.how), ', ') AS "How"`,
159
170
  "FROM events e",
@@ -307,8 +318,9 @@ async function chronologyRoots(db, selves, shape, conditions) {
307
318
  *
308
319
  * @param {string[]} self - The place's Address, in each form.
309
320
  * @param {CorpusShape} shape - What the corpus states.
310
- * @param {{felt?: boolean}} [columns] - `felt: false` when no row of this
311
- * place is felt there, which leaves the `Felt here` column out.
321
+ * @param {{felt?: boolean, calendar?: string}} [columns] - `felt: false` when
322
+ * no row of this place is felt there, which leaves the `Felt here` column
323
+ * out; `calendar`, the page's calendar, which the `When` column prints in.
312
324
  * @returns {{fence: string, own: string}|null} The query, and the one that
313
325
  * counts the place's own rows.
314
326
  */
@@ -332,7 +344,7 @@ function chronologyQuery(self, shape, columns = {}) {
332
344
  ...below,
333
345
  "here AS (SELECT list(place) AS places FROM below)",
334
346
  "SELECT e.note AS _ref,",
335
- ' e.whenText AS "When",',
347
+ ` ${whenColumn(columns.calendar)} AS "When",`,
336
348
  shape.reach && columns.felt !== false ?
337
349
  ' e.summary AS "What happened",'
338
350
  : ' e.summary AS "What happened"',
@@ -453,11 +465,12 @@ export async function eventViewSections(db, records, fileOf, only) {
453
465
  const sections = new Map();
454
466
  for (const view of EVENT_VIEWS) {
455
467
  if (!view.applies(record) || authoredSection(record, view.slug)) continue;
456
- const query = view.query(
457
- self,
458
- shape,
459
- felt.has(view.slug) ? { felt: felt.get(view.slug).has(self[0]) } : undefined,
460
- );
468
+ const query = view.query(self, shape, {
469
+ ...(felt.has(view.slug) ? { felt: felt.get(view.slug).has(self[0]) } : {}),
470
+ ...(calendarAddress(record.resolvedCalendar) ?
471
+ { calendar: calendarAddress(record.resolvedCalendar) }
472
+ : {}),
473
+ });
461
474
  if (!query) continue;
462
475
  const has =
463
476
  owned.has(view.slug) ?
@@ -62,6 +62,7 @@ export const DERIVED_KEYS = Object.freeze([
62
62
  "documents",
63
63
  "asset",
64
64
  "resolvedDates",
65
+ "resolvedCalendar",
65
66
  "citations",
66
67
  "names",
67
68
  ]);
@@ -128,6 +128,7 @@ import {
128
128
  standingLead,
129
129
  } from "./standings.mjs";
130
130
  import { AddressEntries } from "./address-values.mjs";
131
+ import { noteCalendar } from "./note-calendars.mjs";
131
132
 
132
133
  /**
133
134
  * How a section arranges what it holds.
@@ -1066,15 +1067,10 @@ function noteBox(
1066
1067
  : parsed?.qualifier ? dates.eras?.get(parsed.qualifier)
1067
1068
  : null;
1068
1069
  let printable = null;
1069
- if (parsed?.known && data.calendar) {
1070
+ const calendar = noteCalendar(fm, dates);
1071
+ if (parsed?.known && calendar) {
1070
1072
  try {
1071
- printable = formatDateInCalendar(
1072
- parsed,
1073
- isAddressTuple(data.calendar) ?
1074
- renderAddress(data.calendar)
1075
- : data.calendar,
1076
- dates,
1077
- );
1073
+ printable = formatDateInCalendar(parsed, calendar, dates);
1078
1074
  } catch {
1079
1075
  // The calendar reference is checked at the authored field.
1080
1076
  }
@@ -15,11 +15,12 @@ import Handlebars from "handlebars";
15
15
 
16
16
  import { isAddressTuple, renderAddress } from "./address.mjs";
17
17
  import { matchAllOutsideCode, replaceOutsideCode } from "./code-fences.mjs";
18
- import { formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
18
+ import { calendarEras, formatDateInCalendar, parseNoteDate } from "./note-dates.mjs";
19
19
  import { numberWords, numberDigits } from "./number-words.mjs";
20
20
  import { slugify } from "./content-slug.mjs";
21
21
  import { WIKILINK, authoredLabel, isSamePage, parseWikilink } from "./wikilink-syntax.mjs";
22
- import { EVENT_REFERENCE_FIELDS, eventFieldText } from "./event-fields.mjs";
22
+ import { EVENT_REFERENCE_FIELDS, eventFieldText, printEventDate } from "./event-fields.mjs";
23
+ import { noteCalendar } from "./note-calendars.mjs";
23
24
 
24
25
  /**
25
26
  * An inline expression, as an author writes one.
@@ -33,6 +34,48 @@ import { EVENT_REFERENCE_FIELDS, eventFieldText } from "./event-fields.mjs";
33
34
  */
34
35
  export const EXPRESSION = /(?<![\\{])\{\{(?![{<%])([^{}\n]+)\}\}(?!\})/g;
35
36
 
37
+ /**
38
+ * Which of `dateformat`'s arguments is which. Three are a calendar, a date and
39
+ * a format. Two are a calendar and a date, unless the first is absent — a
40
+ * `data.calendar` the note leaves unset is no calendar — or is a date that
41
+ * names no calendar, when they are a date and a format. One is a date.
42
+ *
43
+ * @param {unknown[]} given - The arguments, without Handlebars' options.
44
+ * @param {object} [dates] - The reckoning context.
45
+ * @returns {{calendar?: unknown, authored: unknown, format?: unknown}} The roles.
46
+ */
47
+ function dateformatArguments(given, dates) {
48
+ if (given.length >= 3) {
49
+ const [calendar, authored, format] = given;
50
+ return { calendar: calendar ?? undefined, authored, format };
51
+ }
52
+ if (given.length < 2) return { authored: given[0] };
53
+ const [first, second] = given;
54
+ if (first === undefined || first === null) return { authored: second };
55
+ if (typeof first === "number") return { authored: first, format: second };
56
+ if (typeof first === "string" && !namesCalendar(first, dates)) {
57
+ const parsed = parseNoteDate(first, { ...dates, allowUnknown: false });
58
+ if (parsed.date && !parsed.findings.some((item) => item.severity === "error"))
59
+ return { authored: first, format: second };
60
+ }
61
+ return { calendar: first, authored: second };
62
+ }
63
+
64
+ /**
65
+ * Whether a string names a calendar or one of its eras.
66
+ *
67
+ * @param {string} value - The string.
68
+ * @param {object} [dates] - The reckoning context.
69
+ * @returns {boolean} Whether it resolves to one.
70
+ */
71
+ function namesCalendar(value, dates) {
72
+ try {
73
+ return calendarEras(value, dates).length > 0;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
36
79
  /**
37
80
  * Every helper an expression may call, with the parameters it takes.
38
81
  *
@@ -135,30 +178,42 @@ const HELPERS = Object.freeze([
135
178
  }),
136
179
  Object.freeze({
137
180
  name: "dateformat",
138
- params: 'calendar date ["format"]',
181
+ params: '[calendar] date ["format"]',
139
182
  summary:
140
- "A date written in a calendar. The calendar is an Address or its shortcode, the date " +
141
- "is the value a note carries, and the optional third argument names an output pattern.",
183
+ "A date written in a calendar. The calendar is an Address or its shortcode; left " +
184
+ "out, it is the note's own calendar — its `data.calendar`, else its culture's, " +
185
+ "else for a place its parents', else the configured `defaultCalendar` — and a " +
186
+ "note with none prints the date as written. The date is the value a note " +
187
+ "carries, and the optional last argument names an output pattern.",
142
188
  make:
143
- ({ dates }) =>
144
- (calendar, authored, formatOrOptions) => {
189
+ ({ dates, fm }) =>
190
+ (...args) => {
191
+ const { calendar, authored, format } = dateformatArguments(
192
+ args.slice(0, -1),
193
+ dates,
194
+ );
145
195
  if (
146
- !(typeof calendar === "string" || isAddressTuple(calendar)) ||
147
- !["string", "number"].includes(typeof authored)
196
+ !(
197
+ calendar === undefined ||
198
+ typeof calendar === "string" ||
199
+ isAddressTuple(calendar)
200
+ ) ||
201
+ !["string", "number"].includes(typeof authored) ||
202
+ !(format === undefined || typeof format === "string")
148
203
  )
149
- throw new TypeError("dateformat needs a calendar Address and a date");
204
+ throw new TypeError("dateformat needs a date, and takes a calendar Address");
150
205
  const parsed = parseNoteDate(authored, { ...dates, allowUnknown: false });
151
206
  const error = parsed.findings.find((item) => item.severity === "error");
152
207
  if (error || !parsed.date)
153
208
  throw new RangeError(error?.message ?? "date does not resolve");
154
- const printable = formatDateInCalendar(
155
- parsed.date,
156
- isAddressTuple(calendar) ? renderAddress(calendar) : calendar,
157
- dates,
158
- typeof formatOrOptions === "string" ? formatOrOptions : undefined,
159
- );
209
+ const reference =
210
+ calendar === undefined ? noteCalendar(fm, dates)
211
+ : isAddressTuple(calendar) ? renderAddress(calendar)
212
+ : calendar;
213
+ if (reference === undefined) return printEventDate(authored, parsed.date, dates);
214
+ const printable = formatDateInCalendar(parsed.date, reference, dates, format);
160
215
  if (!printable)
161
- throw new RangeError(`date cannot be printed in calendar ${calendar}`);
216
+ throw new RangeError(`date cannot be printed in calendar ${reference}`);
162
217
  return printable.prose ?? printable.text;
163
218
  },
164
219
  }),