Files
Oxicloud/static/js/core/formatters.js
T
DioCrafts 81a93a489b feat: photo/video capture-date pipeline + premium UI/UX overhaul
Backend — Photos timeline now groups by real capture date instead of upload time. New MediaMetadataService (FileLifecycleHook) extracts EXIF DateTimeOriginal from images and container creation_time from videos (mov/mp4/mkv) via nom-exif, timezone-correct (OffsetTimeOriginal), persisting captured_at so the existing media_sort_date trigger takes over. Adds POST /admin/photos/metadata/reextract to backfill existing media. Falls back to upload date when no embedded date exists.

Frontend — premium grid cards: combined metadata line (relative date · size, owner avatar when shared), custom selection checkbox with a clear checked state, uniform full-width 4:3 thumbnail tiles independent of filename length, centered file-type icons, and a hit-test fix so checkbox/star/kebab clicks reach the controls (the decorative thumbnail no longer captures pointer events). Notification messages internationalised across all 16 locales. Broader polish: design tokens, a11y/focus-visible states, brand + PWA assets.

Chore — bump semver-compatible dependencies (cargo upgrade); add nom-exif 3.6.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 00:24:27 +02:00

408 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OxiCloud - Shared format and escaping utilities
* Centralized global helpers for date/size/text formatting and XSS-safe escaping.
* Contains also checkers
*/
import { i18n } from './i18n.js';
/**
*
* @param {string} str
* @returns {string}
*/
function escapeHtml(str) {
if (typeof str !== 'string') return '';
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
/**
*
* @param {number} bytes
* @returns {string}
*/
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const num = new Intl.NumberFormat(i18n.getCurrentLocale(), { maximumFractionDigits: 2 }).format(
bytes / k ** i
);
return `${num} ${sizes[i]}`;
}
/// Formats a byte count for quota display. When bytes is 0, returns "∞" (unlimited).
/**
*
* @param {number} bytes
* @returns {string}
*/
function formatQuotaSize(bytes) {
if (bytes === 0) return '∞';
return formatFileSize(bytes);
}
/**
*
* @param {Date | number| null} value
* @returns {string}
*/
function formatDateTime(value) {
if (!value) return '';
let dateValue;
if (value instanceof Date) {
dateValue = value;
} else if (typeof value === 'number') {
dateValue = new Date(value < 1e12 ? value * 1000 : value);
} else {
dateValue = new Date(value);
}
if (Number.isNaN(dateValue.getTime())) return String(value);
const loc = i18n.getCurrentLocale();
return `${dateValue.toLocaleDateString(loc)} ${dateValue.toLocaleTimeString(loc, { hour: '2-digit', minute: '2-digit' })}`;
}
/**
*
* @param {Date | number| null} value
* @returns {string}
*/
function formatDateShort(value) {
if (!value) return 'N/A';
const dateValue = typeof value === 'number' ? new Date(value * 1000) : new Date(value);
if (Number.isNaN(dateValue.getTime())) return String(value);
return dateValue.toLocaleDateString(i18n.getCurrentLocale(), {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
/**
* Locale-aware "time ago" via Intl.RelativeTimeFormat (replaces the per-view
* hand-rolled timeAgo helpers).
* @param {Date | number | string | null} value
* @returns {string}
*/
function formatRelativeTime(value) {
if (!value) return '';
const date =
value instanceof Date
? value
: new Date(typeof value === 'number' && value < 1e12 ? value * 1000 : value);
if (Number.isNaN(date.getTime())) return String(value);
const diffSec = Math.round((date.getTime() - Date.now()) / 1000);
const abs = Math.abs(diffSec);
const rtf = new Intl.RelativeTimeFormat(i18n.getCurrentLocale(), { numeric: 'auto' });
/** @type {Array<[Intl.RelativeTimeFormatUnit, number]>} */
const units = [
['year', 31536000],
['month', 2592000],
['week', 604800],
['day', 86400],
['hour', 3600],
['minute', 60]
];
for (const [unit, secs] of units) {
if (abs >= secs) return rtf.format(Math.round(diffSec / secs), unit);
}
return rtf.format(diffSec, 'second');
}
const TEXT_TYPES = [
'application/json',
'application/xml',
'application/javascript',
'application/x-sh',
'application/x-yaml',
'application/toml',
'application/x-toml',
'application/sql'
];
// FIXME: move is to another file
/**
*
* @param {string} mimeType
* @returns {boolean}
*/
function isTextViewable(mimeType) {
if (!mimeType) return false;
if (mimeType.startsWith('text/')) return true;
return TEXT_TYPES.includes(mimeType);
}
/**
* Chekif an email is valid
* @param {string} email
* @returns boolean
*/
function isEmailValid(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
/**
* Normalize a date value into a human-readable bucket label.
* Buckets (newest-first): Today | Last 7 days | Last 30 days | <YYYY>
*
* Accepts:
* - `string` — ISO-8601 date string (e.g. `granted_at` from the API)
* - `number` — Unix timestamp in **seconds** (e.g. `sort_date`, `modified_at`)
* Values < 1e12 are treated as seconds; larger values as milliseconds.
* - `Date` — JavaScript Date object
*
* @param {string | number | Date} value
* @returns {string}
*/
function normalizeDateBucket(value) {
let date;
if (value instanceof Date) {
date = value;
} else if (typeof value === 'number') {
date = new Date(value < 1e12 ? value * 1000 : value);
} else {
date = new Date(value);
}
const diffDays = Math.floor((Date.now() - date.getTime()) / 86_400_000);
if (diffDays === 0) return i18n.t('dateBucket.today', 'Today');
if (diffDays <= 7) return i18n.t('dateBucket.last7days', 'Last 7 days');
if (diffDays <= 30) return i18n.t('dateBucket.last30days', 'Last 30 days');
return String(date.getFullYear());
}
/**
* Normalize a future expiry value into a human-readable bucket label.
* Buckets (soonest-first): Expired | Tomorrow | In less than 7 days | In less than 30 days | <YYYY> | No expiration
*
* Accepts the same input types as `normalizeDateBucket`.
*
* @param {string | number | Date | null | undefined} value
* @returns {string}
*/
function normalizeExpiryBucket(value) {
if (value === null || value === undefined) {
return i18n.t('expiryBucket.noExpiry', 'No expiration');
}
/** @type {Date} */
let date;
if (value instanceof Date) {
date = value;
} else if (typeof value === 'number') {
date = new Date(value < 1e12 ? value * 1000 : value);
} else {
date = new Date(value);
}
const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000);
if (daysUntil < 0) return i18n.t('expiryBucket.expired', 'Expired');
if (daysUntil === 0) return i18n.t('expiryBucket.today', 'Today');
if (daysUntil === 1) return i18n.t('expiryBucket.tomorrow', 'Tomorrow');
if (daysUntil <= 7) return i18n.t('expiryBucket.week', 'In less than 7 days');
if (daysUntil <= 30) return i18n.t('expiryBucket.month', 'In less than 30 days');
return String(date.getFullYear());
}
/**
* Format a future timestamp as a precise "days until" label.
*
* Used by the Trash view's date column to surface the remaining lifetime
* before the retention sweeper purges an item. Unlike `normalizeExpiryBucket`
* (which produces coarse bucket labels for grouping), this returns an
* exact-day count so users can see "In 27 days" at a glance.
*
* Buckets:
* < 0 → Expired
* = 0 → Today
* = 1 → Tomorrow
* > 1 → In N days
*
* @param {string | number | Date | null | undefined} value
* @returns {string}
*/
function formatDaysRemaining(value) {
if (value === null || value === undefined) return '';
/** @type {Date} */
let date;
if (value instanceof Date) {
date = value;
} else if (typeof value === 'number') {
date = new Date(value < 1e12 ? value * 1000 : value);
} else {
date = new Date(value);
}
if (Number.isNaN(date.getTime())) return String(value);
const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000);
if (daysUntil < 0) return i18n.t('daysRemaining.expired', 'Expired');
if (daysUntil === 0) return i18n.t('daysRemaining.today', 'Today');
if (daysUntil === 1) return i18n.t('daysRemaining.tomorrow', 'Tomorrow');
// Translation file holds the `{{count}}` template; the fallback is only
// used pre-load and embeds the literal count.
const translated = i18n.t('daysRemaining.inDays', { count: daysUntil });
return translated === 'daysRemaining.inDays' ? `${daysUntil} days` : translated;
}
/**
* Format a date as a compact "Mar 5, 2026" label.
*
* Shared helper used by `formatExpiryChip` (read-only chip) and by
* `buildExpiryChip` in `utils/expiryChip.js` (interactive editor) so the
* displayed deadline reads identically across both surfaces.
*
* Accepts:
* - `string` YYYY-MM-DD — parsed at LOCAL midnight (avoids the off-by-one
* shift `new Date("2026-05-30")` causes in negative-offset zones).
* - `string` ISO-8601 with time — parsed as-is.
* - `number` Unix seconds/ms (auto-detected at 1e12).
* - `Date` object — used directly.
*
* @param {string | number | Date | null | undefined} value
* @returns {string}
*/
function formatExpiryDate(value) {
if (value === null || value === undefined) return '';
/** @type {Date} */
let date;
if (value instanceof Date) {
date = value;
} else if (typeof value === 'number') {
date = new Date(value < 1e12 ? value * 1000 : value);
} else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
// Bare YYYY-MM-DD: pin to local midnight so the day doesn't shift.
date = new Date(`${value}T00:00:00`);
} else {
date = new Date(value);
}
if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
/**
* Render an expiry / retention date as a tiered chip.
*
* Single shared formatter used by My Shares (link expiry), Trash
* (remaining lifetime before purge), and any other section that needs
* to surface a future deadline. Output:
*
* `<span class="expiry-chip expiry-chip--{tier}"><i class="..."></i>LABEL</span>`
*
* Six tiers escalate cool → hot:
* - null value → `never` (neutral, infinity icon, "Never")
* - > 30 days → `normal` (neutral grey, "Until DATE")
* - 8–30 days → `caution` (soft amber, "N days")
* - 2–7 days → `soon` (soft orange, "N days")
* - 0–1 days → `urgent` (soft red, "Today" / "Tomorrow")
* - past deadline → `expired` (deeper red, warning icon, "Expired")
*
* The CSS lives in `components/expiryChip.css`.
* Caller is responsible for embedding the returned HTML safely — the
* label is taken from a controlled i18n key set (no user input).
*
* @param {string | number | Date | null | undefined} value
* @returns {string} HTML snippet
*/
function formatExpiryChip(value) {
// null/undefined is a valid input meaning "no deadline".
if (value === null || value === undefined) {
const label = i18n.t('expiryChip.never', 'Never expires');
return `<span class="expiry-chip expiry-chip--never"><i class="fas fa-infinity expiry-chip__icon"></i>${escapeHtml(label)}</span>`;
}
/** @type {Date} */
let date;
if (value instanceof Date) {
date = value;
} else if (typeof value === 'number') {
date = new Date(value < 1e12 ? value * 1000 : value);
} else {
date = new Date(value);
}
if (Number.isNaN(date.getTime())) return escapeHtml(String(value));
const daysUntil = Math.floor((date.getTime() - Date.now()) / 86_400_000);
let tier;
let icon;
let label;
if (daysUntil < 0) {
tier = 'expired';
icon = 'fa-exclamation-triangle';
label = i18n.t('expiryChip.expired', 'Expired');
} else if (daysUntil === 0) {
tier = 'urgent';
icon = 'fa-clock';
label = i18n.t('expiryChip.today', 'Expires today');
} else if (daysUntil === 1) {
tier = 'urgent';
icon = 'fa-clock';
label = i18n.t('expiryChip.tomorrow', 'Expires tomorrow');
} else if (daysUntil <= 7) {
tier = 'soon';
icon = 'fa-calendar';
const translated = i18n.t('expiryChip.inDays', { count: daysUntil });
label = translated === 'expiryChip.inDays' ? `Expires in ${daysUntil} days` : translated;
} else if (daysUntil <= 30) {
tier = 'caution';
icon = 'fa-calendar';
const translated = i18n.t('expiryChip.inDays', { count: daysUntil });
label = translated === 'expiryChip.inDays' ? `Expires in ${daysUntil} days` : translated;
} else {
// Far future — show absolute date so users see the exact deadline.
tier = 'normal';
icon = 'fa-calendar';
const fmt = formatExpiryDate(date);
const translated = i18n.t('expiryChip.onDate', { date: fmt });
label = translated === 'expiryChip.onDate' ? `Expires ${fmt}` : translated;
}
return `<span class="expiry-chip expiry-chip--${tier}"><i class="fas ${icon} expiry-chip__icon"></i>${escapeHtml(label)}</span>`;
}
/**
* Maps a file size in bytes to a coarse, human-readable bucket label.
*
* Pass `-1` for folders — they sort before all files on the server and
* receive the "Folders" label client-side.
*
* Buckets:
* -1 → Folders
* 0 → Empty (0 B)
* 1 – 1 048 575 → < 1 MB
* 1 048 576 – 104 857 599 → 1 – 100 MB
* 104 857 600 – 1 073 741 823 → 100 MB – 1 GB
* 1 073 741 824 – 5 368 709 119 → 1 – 5 GB
* ≥ 5 368 709 120 → > 5 GB
*
* @param {number} bytes File size in bytes, or -1 for folders.
* @returns {string}
*/
// biome-ignore format: keep the following indent
function sizeBucket(bytes) {
if (bytes < 0) return i18n.t('sizeBucket.folders', 'Folders');
if (bytes === 0) return i18n.t('sizeBucket.empty', 'Empty (0 B)');
if (bytes < 1_048_576) return i18n.t('sizeBucket.tiny', '< 1 MB');
if (bytes < 104_857_600) return i18n.t('sizeBucket.small', '1 – 100 MB');
if (bytes < 1_073_741_824) return i18n.t('sizeBucket.medium', '100 MB – 1 GB');
if (bytes < 5 * 1_073_741_824) return i18n.t('sizeBucket.large', '1 – 5 GB');
return i18n.t('sizeBucket.huge', '> 5 GB');
}
export {
escapeHtml,
formatDateShort,
formatDateTime,
formatDaysRemaining,
formatExpiryChip,
formatExpiryDate,
formatFileSize,
formatQuotaSize,
formatRelativeTime,
isEmailValid,
isTextViewable,
normalizeDateBucket,
normalizeExpiryBucket,
sizeBucket
};