120 lines
5.2 KiB
TypeScript
120 lines
5.2 KiB
TypeScript
import { bookStatus, noteSeverity, type BookStatus, type Chapter, type Progress } from '../api';
|
||
import { currentLocale } from '../i18n/language';
|
||
import { text, type MessageKey } from '../i18n/text';
|
||
|
||
// Language arrives as a code, never as a word: the second pair of the product has to work without
|
||
// an edit to the markup (the canon's review question, CLAUDE.md §2). Names, numbers and dates are
|
||
// computed for the language of the INTERFACE, which is a choice and therefore not a constant here —
|
||
// one `Intl` set per locale, built once and kept.
|
||
const sets = new Map<string, ReturnType<typeof build>>();
|
||
|
||
const build = (locale: string) => ({
|
||
languages: new Intl.DisplayNames([locale], { type: 'language' }),
|
||
numbers: new Intl.NumberFormat(locale),
|
||
plurals: new Intl.PluralRules(locale),
|
||
// The unit is formatted by Intl and not by us: "MB" is a word, and a word written in the code is
|
||
// a word no translation file can reach. One formatter per magnitude, because the unit is part of
|
||
// the formatter rather than of the value.
|
||
bytes: new Map(
|
||
(['byte', 'kilobyte', 'megabyte', 'gigabyte'] as const).map((unit) => [
|
||
unit,
|
||
new Intl.NumberFormat(locale, { style: 'unit', unit, maximumFractionDigits: 1 }),
|
||
]),
|
||
),
|
||
});
|
||
|
||
function intl() {
|
||
const locale = currentLocale();
|
||
let set = sets.get(locale);
|
||
if (set === undefined) {
|
||
set = build(locale);
|
||
sets.set(locale, set);
|
||
}
|
||
return set;
|
||
}
|
||
|
||
/** A date is a format of the interface, not of the data: the contract's string is always ISO. */
|
||
export const date = (value: string) => new Date(value).toLocaleDateString(currentLocale());
|
||
|
||
export const languageName = (code: string) => intl().languages.of(code) ?? code;
|
||
export const number = (value: number) => intl().numbers.format(value);
|
||
|
||
/**
|
||
* A size of a file, in the unit that suits it. Decimal steps of a thousand and not 1024: that is
|
||
* what the operating system's file manager shows the same file as, and a size the interface states
|
||
* differently from the folder the file was picked from reads as a defect.
|
||
*/
|
||
export function fileSize(bytes: number): string {
|
||
const bare = { unit: 'byte', factor: 1 } as const;
|
||
const steps = [
|
||
{ unit: 'gigabyte', factor: 1e9 },
|
||
{ unit: 'megabyte', factor: 1e6 },
|
||
{ unit: 'kilobyte', factor: 1e3 },
|
||
bare,
|
||
] as const;
|
||
const step = steps.find(({ factor }) => bytes >= factor) ?? bare;
|
||
return (
|
||
intl()
|
||
.bytes.get(step.unit)
|
||
?.format(bytes / step.factor) ?? String(bytes)
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A number with the word it counts: "1 term", "2 terms". The caller names the forms — as catalogue
|
||
* keys, so the words stay in the catalogue — and `Intl` picks the category; no table of endings is
|
||
* written by hand.
|
||
*/
|
||
export function counted(value: number, forms: Record<Intl.LDMLPluralRule, MessageKey>): string {
|
||
return `${number(value)} ${text(forms[intl().plurals.select(value)])}`;
|
||
}
|
||
|
||
/**
|
||
* Severity of a note → look of the callout. The map itself lives on the `src/api/` seam together
|
||
* with the vocabulary, so a new step is added in one file; this is only the call.
|
||
*/
|
||
export const noteTone = (severity: Parameters<typeof noteSeverity.describe>[0]) =>
|
||
noteSeverity.describe(severity).tone;
|
||
|
||
export const statusOf = (status: Parameters<typeof bookStatus.describe>[0]) =>
|
||
bookStatus.describe(status);
|
||
|
||
/**
|
||
* A draft until the run has brought the book to `ready`: both the translation and the chunking are
|
||
* legally rebuilt. An unknown state is a draft too (the safe side); no book means no mark.
|
||
*/
|
||
export const isDraft = (status: BookStatus | null | undefined) =>
|
||
status !== undefined && status !== 'ready';
|
||
|
||
/**
|
||
* What a chapter is called in the tree.
|
||
*
|
||
* The label comes from the DATA of the book, and a template such as "Chapter {n}" is never
|
||
* synthesized here: no such form exists, and a book legally has neither headings nor numbers
|
||
* (D39.100 K-3). What is left when the book gives neither is the position in reading order, which
|
||
* the contract does guarantee — a bare ordinal, not an invented word. Which word, if any, an
|
||
* unnamed chapter should carry is the owner's question В-4.
|
||
*/
|
||
export function chapterLabel(chapter: Chapter, position: number): string {
|
||
if (chapter.heading !== null && chapter.heading !== '') return chapter.heading;
|
||
return String(chapter.number ?? position);
|
||
}
|
||
|
||
/**
|
||
* Progress as ONE number with no phase names: how the pipeline is built is not the user's business
|
||
* (prompt §4.1). Counted over both waves at once, because a per-phase fraction would sit at zero
|
||
* for the whole draft wave — the very trap that makes the DATA phased.
|
||
*/
|
||
export const translatedPercent = (progress: Progress) => {
|
||
const total = progress.draft.total + progress.edit.total;
|
||
return total === 0 ? 0 : Math.round(((progress.draft.done + progress.edit.done) / total) * 100);
|
||
};
|
||
|
||
/** Rough remaining time, in whole minutes or hours. Absent whenever there is nothing to estimate. */
|
||
export function remaining(seconds: number | null | undefined): string | null {
|
||
if (seconds === null || seconds === undefined) return null;
|
||
const minutes = Math.round(seconds / 60);
|
||
if (minutes < 1) return text('time.underMinute');
|
||
if (minutes < 60) return text('time.minutes', { count: number(minutes) });
|
||
return text('time.hours', { count: number(Math.round(minutes / 60)) });
|
||
}
|