// Pure helpers for the Printed Folio restyle of /notes, shared by Eleventy, the sync script and the browser. Keep it import-free. const DAY_MS = 86400000; /** Growth stages, least grown first. */ export const STAGES = ['found', 'draft', 'in bloom']; /** Stage name to glyph name. */ /** @type {Record} */ export const GLYPHS = { found: 'seed', draft: 'sprout', 'in bloom': 'bloom' }; /** Retired stage names, still accepted from NotePlan and old stage histories. */ /** @type {Record} */ export const STAGE_ALIASES = { 'in progress': 'draft', finished: 'in bloom' }; /** * A stage as written in front matter ("In-Progress", "finished") to its current name. * @param {unknown} stage * @returns {string} lowercased, hyphens as spaces, retired names mapped; may still be unknown */ export function normalizeStage(stage) { const name = String(stage).trim().toLowerCase().replace(/-/g, ' '); return Object.hasOwn(STAGE_ALIASES, name) ? STAGE_ALIASES[name] : name; } /** * How grown a stage is: its index in STAGES. Unknown and retired names are read through normalizeStage; unknown ranks 0. * @param {string} stage */ export function stageRank(stage) { return Math.max(0, STAGES.indexOf(normalizeStage(stage))); } /** * Whether a move between stages is a promotion: to a more grown stage. Retired names read as their successors. * @param {string} from * @param {string} to */ export function isPromotion(from, to) { return stageRank(to) > stageRank(from); } /** * The order notes are listed in, as a sort comparator. By 'stage': most grown first, then most recently tended, * then title. By 'tended' or 'planted': that date, newest first, then title. * @param {{ stage: string, tended: string, planted: string, title: string }} a * @param {{ stage: string, tended: string, planted: string, title: string }} b * @param {'stage'|'tended'|'planted'} [key] */ export function compareNotes(a, b, key = 'stage') { const date = key === 'planted' ? 'planted' : 'tended'; return (key === 'stage' ? stageRank(b.stage) - stageRank(a.stage) : 0) || (a[date] < b[date] ? 1 : a[date] > b[date] ? -1 : 0) || a.title.localeCompare(b.title); } /** * The most grown of some notes' stages, as its current name: what a tag's seed packet prints on its front. * @param {string[]} stages * @returns {string} 'found' when there are none */ export function mostGrownStage(stages) { return STAGES[Math.max(0, ...stages.map(stageRank))]; } /** * How many notes are at each stage, every stage in STAGES order: the tally at the foot of a seed packet's back. * @param {string[]} stages * @returns {{ stage: string, count: number }[]} */ export function stageTally(stages) { const ranks = stages.map(stageRank); return STAGES.map((stage, i) => ({ stage, count: ranks.filter((r) => r === i).length })); } /** * The glyph for a note's stage, read through normalizeStage; unknown or inherited names fall back to 'seed'. * @param {string} stage * @returns {'seed'|'sprout'|'bloom'} */ export function glyphFor(stage) { const name = normalizeStage(stage); return Object.hasOwn(GLYPHS, name) ? GLYPHS[name] : 'seed'; } /** * A stage's two-plate riso sprig, from the symbols in the page's sprite (sprigs.svg.njk). Decorative: the stage * name is always printed beside it in text. * @param {string} stage */ export function sprigMarkup(stage) { const g = glyphFor(stage); return ``; } /** * A catalogue accession number such as "N° 011". Numbers over 999 keep all their digits. * @param {number|undefined} n * @returns {string} "" when n is not a positive integer */ export function accession(n) { if (n === undefined || !Number.isInteger(n) || n < 1) return ''; return 'N° ' + String(n).padStart(3, '0'); } /** * How long ago a note was last tended, in whole UTC days. * @param {string} tended YYYY-MM-DD * @param {string} buildDate YYYY-MM-DD * @returns {'fresh'|'season'|'year'|'dormant'} future or malformed dates count as fresh */ export function ageBucket(tended, buildDate) { const days = Math.floor((Date.parse(buildDate + 'T00:00:00Z') - Date.parse(tended + 'T00:00:00Z')) / DAY_MS); if (!(days >= 0)) return 'fresh'; if (days < 90) return 'fresh'; if (days < 365) return 'season'; if (days < 730) return 'year'; return 'dormant'; } /** * A date with its separators as middle dots: "2026-10-09" becomes "2026·10·09". * @param {string} iso YYYY-MM-DD * @returns {string} */ export function dotDate(iso) { return iso.replaceAll('-', '·'); }