This commit introduces several enhancements across the EntryDetailModal and ObjectTable components. Key changes include the addition of a FormattedFieldValue component for consistent display of various field types, improved handling of entry metadata, and the introduction of input type resolution for fields. Additionally, navigation callbacks for entries have been refined to support better interaction within the object table. These updates aim to streamline data presentation and enhance user experience.
502 lines
11 KiB
TypeScript
502 lines
11 KiB
TypeScript
import { buildFileLink, parseWorkspaceLink } from "./workspace-links";
|
|
|
|
export type WorkspaceMediaType = "image" | "video" | "audio" | "pdf";
|
|
|
|
export type WorkspaceLinkType = "url" | "email" | "phone" | "file";
|
|
|
|
export type FormattedWorkspaceValue = {
|
|
kind: "empty" | "text" | "number" | "currency" | "date" | "link";
|
|
raw: string;
|
|
text: string;
|
|
linkType?: WorkspaceLinkType;
|
|
href?: string;
|
|
filePath?: string;
|
|
mediaType?: WorkspaceMediaType;
|
|
embedUrl?: string;
|
|
numericValue?: number;
|
|
isoDate?: string;
|
|
};
|
|
|
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
const URL_RE = /^https?:\/\/\S+$/i;
|
|
const PHONE_RE = /^\+?[0-9().\-\s]{7,}$/;
|
|
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:[T\s]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}(?::?\d{2})?)?)?$/;
|
|
const SLASH_DATE_RE = /^\d{1,2}\/\d{1,2}\/\d{2,4}$/;
|
|
const CURRENCY_RE = /^([$\u20ac\u00a3\u00a5])\s*(-?\d[\d,]*(?:\.\d+)?)$/;
|
|
const NUMBER_RE = /^-?\d[\d,]*(?:\.\d+)?$/;
|
|
|
|
const IMAGE_EXTS = new Set([
|
|
"jpg",
|
|
"jpeg",
|
|
"png",
|
|
"gif",
|
|
"webp",
|
|
"svg",
|
|
"bmp",
|
|
"avif",
|
|
"heic",
|
|
"heif",
|
|
"ico",
|
|
"tiff",
|
|
"tif",
|
|
]);
|
|
const VIDEO_EXTS = new Set(["mp4", "webm", "mov", "avi", "mkv"]);
|
|
const AUDIO_EXTS = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac"]);
|
|
const PDF_EXTS = new Set(["pdf"]);
|
|
const KNOWN_FILE_EXTS = new Set([
|
|
...IMAGE_EXTS,
|
|
...VIDEO_EXTS,
|
|
...AUDIO_EXTS,
|
|
...PDF_EXTS,
|
|
"txt",
|
|
"md",
|
|
"json",
|
|
"yaml",
|
|
"yml",
|
|
"csv",
|
|
"sql",
|
|
"doc",
|
|
"docx",
|
|
"xls",
|
|
"xlsx",
|
|
"ppt",
|
|
"pptx",
|
|
"zip",
|
|
"tar",
|
|
"gz",
|
|
]);
|
|
|
|
function normalizeFieldType(fieldType: string | undefined): string {
|
|
return (fieldType ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
export function toDisplayString(value: unknown): string {
|
|
if (value == null) {return "";}
|
|
if (typeof value === "string") {return value;}
|
|
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
return String(value);
|
|
}
|
|
if (typeof value === "object") {
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function getExtension(pathLike: string): string {
|
|
const clean = pathLike.split("?")[0]?.split("#")[0] ?? pathLike;
|
|
const ext = clean.split(".").pop()?.toLowerCase() ?? "";
|
|
return ext;
|
|
}
|
|
|
|
export function detectFileMediaType(pathLike: string): WorkspaceMediaType | undefined {
|
|
const ext = getExtension(pathLike);
|
|
if (!ext) {return undefined;}
|
|
if (IMAGE_EXTS.has(ext)) {return "image";}
|
|
if (VIDEO_EXTS.has(ext)) {return "video";}
|
|
if (AUDIO_EXTS.has(ext)) {return "audio";}
|
|
if (PDF_EXTS.has(ext)) {return "pdf";}
|
|
return undefined;
|
|
}
|
|
|
|
function looksLikeAbsoluteFsPath(value: string): boolean {
|
|
return value.startsWith("/") || value.startsWith("~/") || /^[A-Za-z]:[\\/]/.test(value);
|
|
}
|
|
|
|
function decodeFileUrl(value: string): string | null {
|
|
if (!value.startsWith("file://")) {return null;}
|
|
try {
|
|
const url = new URL(value);
|
|
if (url.protocol !== "file:") {return null;}
|
|
const decoded = decodeURIComponent(url.pathname);
|
|
if (/^\/[A-Za-z]:\//.test(decoded)) {
|
|
return decoded.slice(1);
|
|
}
|
|
return decoded;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function looksLikeFilePath(value: string): boolean {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {return false;}
|
|
if (trimmed.includes("://") && !trimmed.startsWith("file://")) {return false;}
|
|
if (trimmed.startsWith("./") || trimmed.startsWith("../") || looksLikeAbsoluteFsPath(trimmed)) {
|
|
return true;
|
|
}
|
|
const hasSlash = trimmed.includes("/") || trimmed.includes("\\");
|
|
const ext = getExtension(trimmed);
|
|
if (hasSlash) {
|
|
return ext.length > 0 && ext.length <= 8;
|
|
}
|
|
return KNOWN_FILE_EXTS.has(ext);
|
|
}
|
|
|
|
function inferFilePath(raw: string): string | null {
|
|
const parsed = parseWorkspaceLink(raw);
|
|
if (parsed?.kind === "file") {
|
|
return parsed.path;
|
|
}
|
|
|
|
const fromFileUrl = decodeFileUrl(raw);
|
|
if (fromFileUrl) {
|
|
return fromFileUrl;
|
|
}
|
|
|
|
const trimmed = raw.trim();
|
|
if (!looksLikeFilePath(trimmed)) {
|
|
return null;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function normalizeUrl(raw: string): string | null {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {return null;}
|
|
if (trimmed.startsWith("www.")) {
|
|
return `https://${trimmed}`;
|
|
}
|
|
if (!URL_RE.test(trimmed)) {
|
|
return null;
|
|
}
|
|
try {
|
|
const u = new URL(trimmed);
|
|
if (u.protocol !== "http:" && u.protocol !== "https:") {
|
|
return null;
|
|
}
|
|
return u.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizePhone(raw: string): string | null {
|
|
const trimmed = raw.trim();
|
|
if (!PHONE_RE.test(trimmed)) {
|
|
return null;
|
|
}
|
|
const digits = trimmed.replace(/\D/g, "");
|
|
if (digits.length < 7) {
|
|
return null;
|
|
}
|
|
const telTarget = trimmed.replace(/[^\d+]/g, "");
|
|
if (!telTarget) {
|
|
return null;
|
|
}
|
|
return `tel:${telTarget}`;
|
|
}
|
|
|
|
function parseLooseNumber(raw: string): number | null {
|
|
const normalized = raw.replace(/,/g, "").trim();
|
|
if (!normalized) {return null;}
|
|
const parsed = Number(normalized);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function formatNumber(value: number): string {
|
|
return new Intl.NumberFormat(undefined, {
|
|
maximumFractionDigits: 6,
|
|
}).format(value);
|
|
}
|
|
|
|
function currencyCodeForSymbol(symbol: string): string | null {
|
|
switch (symbol) {
|
|
case "$":
|
|
return "USD";
|
|
case "\u20ac":
|
|
return "EUR";
|
|
case "\u00a3":
|
|
return "GBP";
|
|
case "\u00a5":
|
|
return "JPY";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function formatCurrency(symbol: string, amount: number): string {
|
|
const code = currencyCodeForSymbol(symbol);
|
|
if (!code) {
|
|
return `${symbol}${formatNumber(amount)}`;
|
|
}
|
|
return new Intl.NumberFormat(undefined, {
|
|
style: "currency",
|
|
currency: code,
|
|
maximumFractionDigits: 2,
|
|
}).format(amount);
|
|
}
|
|
|
|
function normalizeIsoDateTime(raw: string): string {
|
|
let normalized = raw;
|
|
if (normalized.includes(" ")) {
|
|
normalized = normalized.replace(" ", "T");
|
|
}
|
|
|
|
// JS Date only keeps millisecond precision; trim extra fractional digits.
|
|
normalized = normalized.replace(
|
|
/(\.\d{3})\d+(?=(?:Z|[+-]\d{2}(?::?\d{2})?)?$)/,
|
|
"$1",
|
|
);
|
|
|
|
// DuckDB can emit short timezone offsets like -08; normalize to -08:00.
|
|
normalized = normalized.replace(/([+-]\d{2})$/, "$1:00");
|
|
// Also normalize compact offsets like +0530 -> +05:30.
|
|
normalized = normalized.replace(/([+-]\d{2})(\d{2})$/, "$1:$2");
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function parseDate(raw: string): Date | null {
|
|
const trimmed = raw.trim();
|
|
if (DATE_ONLY_RE.test(trimmed)) {
|
|
const [y, m, d] = trimmed.split("-").map((part) => Number(part));
|
|
const localDate = new Date(y, (m ?? 1) - 1, d ?? 1);
|
|
return Number.isNaN(localDate.getTime()) ? null : localDate;
|
|
}
|
|
if (!(ISO_DATE_RE.test(trimmed) || SLASH_DATE_RE.test(trimmed))) {
|
|
return null;
|
|
}
|
|
const d = new Date(normalizeIsoDateTime(trimmed));
|
|
if (Number.isNaN(d.getTime())) {
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
function formatDate(raw: string): { text: string; iso: string } | null {
|
|
const d = parseDate(raw);
|
|
if (!d) {return null;}
|
|
const hasTime = raw.includes("T") || /\d{1,2}:\d{2}/.test(raw);
|
|
const text = hasTime
|
|
? new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
}).format(d)
|
|
: new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(d);
|
|
return { text, iso: d.toISOString() };
|
|
}
|
|
|
|
export function buildRawFileUrl(path: string): string {
|
|
if (path.startsWith("http://") || path.startsWith("https://")) {
|
|
return path;
|
|
}
|
|
if (looksLikeAbsoluteFsPath(path)) {
|
|
return `/api/workspace/browse-file?path=${encodeURIComponent(path)}&raw=true`;
|
|
}
|
|
return `/api/workspace/raw-file?path=${encodeURIComponent(path)}`;
|
|
}
|
|
|
|
function formatBySchema(raw: string, fieldType: string): FormattedWorkspaceValue | null {
|
|
if (fieldType === "email" && EMAIL_RE.test(raw.trim())) {
|
|
const email = raw.trim();
|
|
return {
|
|
kind: "link",
|
|
raw,
|
|
text: email,
|
|
linkType: "email",
|
|
href: `mailto:${email}`,
|
|
};
|
|
}
|
|
if (fieldType === "phone") {
|
|
const telHref = normalizePhone(raw);
|
|
if (telHref) {
|
|
return {
|
|
kind: "link",
|
|
raw,
|
|
text: raw.trim(),
|
|
linkType: "phone",
|
|
href: telHref,
|
|
};
|
|
}
|
|
}
|
|
if (fieldType === "url") {
|
|
const href = normalizeUrl(raw);
|
|
if (href) {
|
|
return {
|
|
kind: "link",
|
|
raw,
|
|
text: raw.trim(),
|
|
linkType: "url",
|
|
href,
|
|
mediaType: detectFileMediaType(href),
|
|
embedUrl: href,
|
|
};
|
|
}
|
|
}
|
|
if (fieldType === "date") {
|
|
const formatted = formatDate(raw);
|
|
if (formatted) {
|
|
return {
|
|
kind: "date",
|
|
raw,
|
|
text: formatted.text,
|
|
isoDate: formatted.iso,
|
|
};
|
|
}
|
|
}
|
|
if (fieldType === "number") {
|
|
const currMatch = raw.trim().match(CURRENCY_RE);
|
|
if (currMatch) {
|
|
const amount = parseLooseNumber(currMatch[2] ?? "");
|
|
if (amount != null) {
|
|
return {
|
|
kind: "currency",
|
|
raw,
|
|
text: formatCurrency(currMatch[1] ?? "$", amount),
|
|
numericValue: amount,
|
|
};
|
|
}
|
|
}
|
|
const n = parseLooseNumber(raw);
|
|
if (n != null) {
|
|
return {
|
|
kind: "number",
|
|
raw,
|
|
text: formatNumber(n),
|
|
numericValue: n,
|
|
};
|
|
}
|
|
}
|
|
if (fieldType === "file") {
|
|
const filePath = inferFilePath(raw);
|
|
if (filePath) {
|
|
return {
|
|
kind: "link",
|
|
raw,
|
|
text: filePath,
|
|
linkType: "file",
|
|
href: buildFileLink(filePath),
|
|
filePath,
|
|
mediaType: detectFileMediaType(filePath),
|
|
embedUrl: buildRawFileUrl(filePath),
|
|
};
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatByHeuristics(raw: string): FormattedWorkspaceValue {
|
|
const trimmed = raw.trim();
|
|
|
|
const filePath = inferFilePath(trimmed);
|
|
if (filePath) {
|
|
return {
|
|
kind: "link",
|
|
raw,
|
|
text: filePath,
|
|
linkType: "file",
|
|
href: buildFileLink(filePath),
|
|
filePath,
|
|
mediaType: detectFileMediaType(filePath),
|
|
embedUrl: buildRawFileUrl(filePath),
|
|
};
|
|
}
|
|
|
|
if (EMAIL_RE.test(trimmed)) {
|
|
return {
|
|
kind: "link",
|
|
raw,
|
|
text: trimmed,
|
|
linkType: "email",
|
|
href: `mailto:${trimmed}`,
|
|
};
|
|
}
|
|
|
|
const url = normalizeUrl(trimmed);
|
|
if (url) {
|
|
return {
|
|
kind: "link",
|
|
raw,
|
|
text: trimmed,
|
|
linkType: "url",
|
|
href: url,
|
|
mediaType: detectFileMediaType(url),
|
|
embedUrl: url,
|
|
};
|
|
}
|
|
|
|
const tel = normalizePhone(trimmed);
|
|
if (tel) {
|
|
return {
|
|
kind: "link",
|
|
raw,
|
|
text: trimmed,
|
|
linkType: "phone",
|
|
href: tel,
|
|
};
|
|
}
|
|
|
|
const date = formatDate(trimmed);
|
|
if (date) {
|
|
return {
|
|
kind: "date",
|
|
raw,
|
|
text: date.text,
|
|
isoDate: date.iso,
|
|
};
|
|
}
|
|
|
|
const currMatch = trimmed.match(CURRENCY_RE);
|
|
if (currMatch) {
|
|
const amount = parseLooseNumber(currMatch[2] ?? "");
|
|
if (amount != null) {
|
|
return {
|
|
kind: "currency",
|
|
raw,
|
|
text: formatCurrency(currMatch[1] ?? "$", amount),
|
|
numericValue: amount,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (NUMBER_RE.test(trimmed)) {
|
|
const n = parseLooseNumber(trimmed);
|
|
if (n != null) {
|
|
return {
|
|
kind: "number",
|
|
raw,
|
|
text: formatNumber(n),
|
|
numericValue: n,
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
kind: "text",
|
|
raw,
|
|
text: raw,
|
|
};
|
|
}
|
|
|
|
export function formatWorkspaceFieldValue(
|
|
value: unknown,
|
|
fieldType?: string,
|
|
): FormattedWorkspaceValue {
|
|
const raw = toDisplayString(value);
|
|
if (!raw || raw.trim().length === 0) {
|
|
return { kind: "empty", raw, text: "" };
|
|
}
|
|
|
|
const schemaType = normalizeFieldType(fieldType);
|
|
const schemaFormatted = formatBySchema(raw, schemaType);
|
|
if (schemaFormatted) {
|
|
return schemaFormatted;
|
|
}
|
|
|
|
// Limit heuristic formatting on rich text / relation-like fields.
|
|
if (schemaType === "richtext" || schemaType === "relation" || schemaType === "enum" || schemaType === "user") {
|
|
return {
|
|
kind: "text",
|
|
raw,
|
|
text: raw,
|
|
};
|
|
}
|
|
|
|
return formatByHeuristics(raw);
|
|
}
|