Add distributed settings and workspace import/export (M5)

This commit is contained in:
2026-06-07 15:51:00 +03:00
parent 80bedd2a8d
commit 548aa199d9
38 changed files with 3150 additions and 101 deletions
+137
View File
@@ -0,0 +1,137 @@
/**
* Date formatting — how timestamps render throughout the app (spec §07 →
* Formatting). Governs the Snippet Library list dates and the metadata panel's
* Created/Modified, driven by the user's `formatting.dateFormat` setting.
*
* Portable core: no browser APIs, no React. `Intl` is deliberately avoided for
* the custom tokens so output is locale-stable and unit-testable; month/day
* names are the fixed English set. Pure: an ISO string in, a display string out.
*
* - smart → relative, human-friendly ("Today", "Yesterday", "3d ago", then a
* full locale date for older items).
* - iso → a full ISO 8601 timestamp.
* - custom → the user's format string (token grammar below); falls back to ISO
* when the pattern is empty.
*/
/** The three date-display modes (mirrors UserSettings.formatting.dateFormat). */
export type DateFormatMode = 'smart' | 'iso' | 'custom';
const DAY_MS = 24 * 60 * 60 * 1000;
const MONTHS_SHORT = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const MONTHS_LONG = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
/** Two-digit zero-pad (mirrors snippet.ts → generateSnippetName). */
function pad(n: number): string {
return String(n).padStart(2, '0');
}
/**
* Relative, human-friendly rendering (spec §07 → Smart). Same day → "Today",
* one calendar day back → "Yesterday", under a week → "Nd ago", else the full
* locale date. Comparison is by calendar day (local), so "Yesterday" doesn't
* flip on the exact hour. `now` is injectable for deterministic tests.
*/
export function formatSmart(date: Date, now: Date): string {
const startOfDay = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
const days = Math.floor((startOfDay(now) - startOfDay(date)) / DAY_MS);
if (days <= 0) return 'Today';
if (days === 1) return 'Yesterday';
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString();
}
/** Full ISO 8601 timestamp (spec §07 → ISO 8601). */
export function formatIso(date: Date): string {
return date.toISOString();
}
// Tokens longest-first so MMMM matches before MMM before MM before M, etc.
const TOKEN = /yyyy|yy|MMMM|MMM|MM|M|dd|d|HH|H|hh|h|mm|m|ss|s|a/g;
/**
* Format a date with a date-fns-style token pattern, in **local** time
* (spec §07 → Custom). Supported tokens: `yyyy yy MMMM MMM MM M dd d HH H hh h
* mm m ss s a`. Text between tokens is preserved verbatim (no quoting), which is
* enough for patterns like `yyyy-MM-dd HH:mm`; a stray literal letter that
* happens to be a token (e.g. a `d` in prose) would be substituted — the field
* is a power-user affordance, so this stays simple and predictable.
*/
export function formatCustom(date: Date, pattern: string): string {
const h24 = date.getHours();
const h12 = h24 % 12 === 0 ? 12 : h24 % 12;
const map: Record<string, string> = {
yyyy: String(date.getFullYear()),
yy: pad(date.getFullYear() % 100),
MMMM: MONTHS_LONG[date.getMonth()],
MMM: MONTHS_SHORT[date.getMonth()],
MM: pad(date.getMonth() + 1),
M: String(date.getMonth() + 1),
dd: pad(date.getDate()),
d: String(date.getDate()),
HH: pad(h24),
H: String(h24),
hh: pad(h12),
h: String(h12),
mm: pad(date.getMinutes()),
m: String(date.getMinutes()),
ss: pad(date.getSeconds()),
s: String(date.getSeconds()),
a: h24 < 12 ? 'AM' : 'PM',
};
return pattern.replace(TOKEN, (t) => map[t] ?? t);
}
/**
* Render an ISO timestamp per the user's date-format setting (spec §07). An
* unparseable timestamp is returned verbatim rather than rendered as "Invalid
* Date", so a malformed stored value degrades gracefully. `customFormat` is used
* only in `custom` mode and falls back to ISO when blank. `now` (for `smart`)
* defaults to the current time; inject it in tests.
*/
export function formatDate(
iso: string,
mode: DateFormatMode,
customFormat = '',
now: Date = new Date(),
): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
switch (mode) {
case 'iso':
return formatIso(date);
case 'custom':
return customFormat.trim() === '' ? formatIso(date) : formatCustom(date, customFormat);
case 'smart':
default:
return formatSmart(date, now);
}
}