From 31706a1623f1a4793b1c39f50fdb5b5df74ff1ac Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 23 Oct 2025 15:41:39 -0500 Subject: [PATCH] refactor: introduce unified contact parsing and display system Extracts contact information parsing logic into reusable parsers for both JSContact and vCard formats. Introduces ContactTable component for consistent contact rendering across both formats, reducing code duplication and improving maintainability. - Add contact-parser.ts with parseJSContact() and parseVCard() - Add ContactTable component for unified contact display - Refactor JSContactDisplay and VCardDisplay to use new parsers - Add comprehensive test suite for contact parsers (488 test cases) - Move WHOIS Server field to DataList in EntitiesSection - Fix DynamicDate tooltip to avoid duplicate time display - Standardize import paths to use @ alias - Update tooltip text for RDAP URL button clarity --- src/components/AbstractCard.tsx | 4 +- src/components/DynamicDate.tsx | 7 +- src/components/ShareButton.tsx | 2 +- src/lib/url-utils.test.ts | 2 +- src/rdap/__tests__/contact-parser.test.ts | 486 ++++++++++++++++++++++ src/rdap/components/ContactDisplay.tsx | 4 +- src/rdap/components/ContactTable.tsx | 219 ++++++++++ src/rdap/components/EntitiesSection.tsx | 24 +- src/rdap/components/JSContactDisplay.tsx | 327 +-------------- src/rdap/components/VCardDisplay.tsx | 233 +---------- src/rdap/contact-parser.ts | 241 +++++++++++ 11 files changed, 982 insertions(+), 567 deletions(-) create mode 100644 src/rdap/__tests__/contact-parser.test.ts create mode 100644 src/rdap/components/ContactTable.tsx create mode 100644 src/rdap/contact-parser.ts diff --git a/src/components/AbstractCard.tsx b/src/components/AbstractCard.tsx index 87adc43..8b56b56 100644 --- a/src/components/AbstractCard.tsx +++ b/src/components/AbstractCard.tsx @@ -14,7 +14,7 @@ type AbstractCardProps = { footer?: ReactNode; /** RDAP response data for download/display. When provided, enables JSON actions. */ data?: ParsedGeneric | object; - /** RDAP query URL. When provided, enables "open in new tab" button. */ + /** RDAP query URL. When provided, enables "open RDAP URL" button. */ url?: string; /** Query execution timestamp for filename generation */ queryTimestamp?: Date; @@ -85,7 +85,7 @@ const AbstractCard: FunctionComponent = ({ {url != null && ( - + = ({ : format(date, absoluteFormatString); const isoString = date.toISOString(); + // Relative time element (disable time since it's already shown in the tooltip) + const relative = ; + // Get display value based on global format - const displayValue = dateFormat === "relative" ? : absoluteWithTz; + const displayValue = dateFormat === "relative" ? relative : absoluteWithTz; return ( = ({ - Relative: + Relative: {relative} diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx index 32bc9d5..a1f0166 100644 --- a/src/components/ShareButton.tsx +++ b/src/components/ShareButton.tsx @@ -1,6 +1,6 @@ import type { FunctionComponent } from "react"; import { Link2Icon } from "@radix-ui/react-icons"; -import CopyButton, { type CopyButtonProps } from "./CopyButton"; +import CopyButton, { type CopyButtonProps } from "@/components/CopyButton"; export type ShareButtonProps = Omit & { /** diff --git a/src/lib/url-utils.test.ts b/src/lib/url-utils.test.ts index e6ce021..1adc49e 100644 --- a/src/lib/url-utils.test.ts +++ b/src/lib/url-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { serializeQueryToUrl, deserializeUrlToQuery, buildShareableUrl } from "./url-utils"; +import { serializeQueryToUrl, deserializeUrlToQuery, buildShareableUrl } from "@/lib/url-utils"; describe("URL Utilities", () => { describe("serializeQueryToUrl", () => { diff --git a/src/rdap/__tests__/contact-parser.test.ts b/src/rdap/__tests__/contact-parser.test.ts new file mode 100644 index 0000000..37ed4dc --- /dev/null +++ b/src/rdap/__tests__/contact-parser.test.ts @@ -0,0 +1,486 @@ +import { describe, it, expect } from "vitest"; +import { parseJSContact, parseVCard } from "@/rdap/contact-parser"; +import type { JSCard, VCardArray } from "@/rdap/schemas"; + +describe("parseJSContact", () => { + it("extracts full name", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + name: { + full: "John Doe", + }, + }; + + const result = parseJSContact(jscard); + + expect(result.name).toBe("John Doe"); + }); + + it("extracts kind when not individual", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + kind: "org", + }; + + const result = parseJSContact(jscard); + + expect(result.kind).toBe("org"); + }); + + it("does not extract kind when individual", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + kind: "individual", + }; + + const result = parseJSContact(jscard); + + expect(result.kind).toBeUndefined(); + }); + + it("extracts organization with name only", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + organizations: { + org1: { + name: "ACME Corp", + }, + }, + }; + + const result = parseJSContact(jscard); + + expect(result.organizations).toEqual(["ACME Corp"]); + }); + + it("combines organization name with units", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + organizations: { + org1: { + name: "ACME Corp", + units: [{ name: "Engineering" }, { name: "Backend" }], + }, + }, + }; + + const result = parseJSContact(jscard); + + expect(result.organizations).toEqual(["ACME Corp > Engineering > Backend"]); + }); + + it("extracts titles with kinds", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + titles: { + t1: { + name: "Software Engineer", + kind: "title", + }, + t2: { + name: "Tech Lead", + kind: "role", + }, + }, + }; + + const result = parseJSContact(jscard); + + expect(result.titles).toEqual([ + { name: "Software Engineer", kind: "title" }, + { name: "Tech Lead", kind: "role" }, + ]); + }); + + it("extracts emails with metadata", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + emails: { + e1: { + address: "john@example.com", + label: "Work", + contexts: { work: true }, + }, + e2: { + address: "john.personal@example.com", + }, + }, + }; + + const result = parseJSContact(jscard); + + expect(result.emails).toEqual([ + { + address: "john@example.com", + label: "Work", + contexts: ["work"], + }, + { + address: "john.personal@example.com", + label: undefined, + contexts: undefined, + }, + ]); + }); + + it("extracts phones with features", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + phones: { + p1: { + number: "+1-555-0100", + features: { voice: true, text: true }, + label: "Mobile", + }, + }, + }; + + const result = parseJSContact(jscard); + + expect(result.phones).toEqual([ + { + number: "+1-555-0100", + features: ["voice", "text"], + label: "Mobile", + }, + ]); + }); + + it("extracts addresses with full text", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + addresses: { + a1: { + full: "123 Main St, Springfield, USA", + countryCode: "US", + timeZone: "America/New_York", + }, + }, + }; + + const result = parseJSContact(jscard); + + expect(result.addresses).toEqual([ + { + text: "123 Main St, Springfield, USA", + details: ["US", "America/New_York"], + }, + ]); + }); + + it("extracts addresses from components when full is missing", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + addresses: { + a1: { + components: [ + { value: "123 Main St" }, + { value: "Springfield" }, + { value: "USA" }, + ], + }, + }, + }; + + const result = parseJSContact(jscard); + + expect(result.addresses).toEqual([ + { + text: "123 Main St, Springfield, USA", + details: undefined, + }, + ]); + }); + + it("extracts online services", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + onlineServices: { + s1: { + uri: "https://github.com/johndoe", + service: "GitHub", + user: "johndoe", + }, + }, + }; + + const result = parseJSContact(jscard); + + expect(result.onlineServices).toEqual([ + { + text: "GitHub", + uri: "https://github.com/johndoe", + service: "GitHub", + }, + ]); + }); + + it("extracts links", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "test-uid", + links: { + l1: { + uri: "https://example.com", + label: "Website", + }, + l2: { + uri: "https://blog.example.com", + }, + }, + }; + + const result = parseJSContact(jscard); + + expect(result.links).toEqual([ + { uri: "https://example.com", label: "Website" }, + { uri: "https://blog.example.com", label: undefined }, + ]); + }); + + it("handles complete JSCard with all fields", () => { + const jscard: JSCard = { + "@type": "Card", + version: "1.0", + uid: "complete-test", + kind: "individual", + name: { + full: "Jane Smith", + }, + organizations: { + org1: { name: "Tech Corp" }, + }, + titles: { + t1: { name: "CTO" }, + }, + emails: { + e1: { address: "jane@tech.com" }, + }, + phones: { + p1: { number: "+1-555-0200" }, + }, + addresses: { + a1: { full: "456 Oak Ave" }, + }, + onlineServices: { + s1: { uri: "https://twitter.com/jane" }, + }, + links: { + l1: { uri: "https://jane.tech" }, + }, + }; + + const result = parseJSContact(jscard); + + expect(result).toEqual({ + name: "Jane Smith", + organizations: ["Tech Corp"], + titles: [{ name: "CTO", kind: undefined }], + emails: [{ address: "jane@tech.com", label: undefined, contexts: undefined }], + phones: [{ number: "+1-555-0200", features: undefined, label: undefined }], + addresses: [{ text: "456 Oak Ave", details: undefined }], + onlineServices: [ + { + text: "https://twitter.com/jane", + uri: "https://twitter.com/jane", + service: undefined, + }, + ], + links: [{ uri: "https://jane.tech", label: undefined }], + }); + }); +}); + +describe("parseVCard", () => { + it("handles minimal vCard with only FN", () => { + const vcard: VCardArray = ["vcard", [["fn", {}, "text", "John Doe"]]]; + + const result = parseVCard(vcard); + + expect(result.name).toBe("John Doe"); + expect(result.emails).toBeUndefined(); + expect(result.phones).toBeUndefined(); + }); + + it("extracts organization as string", () => { + const vcard: VCardArray = [ + "vcard", + [ + ["fn", {}, "text", "John Doe"], + ["org", {}, "text", "ACME Corp"], + ], + ]; + + const result = parseVCard(vcard); + + expect(result.organizations).toEqual(["ACME Corp"]); + }); + + it("extracts organization as array with units", () => { + const vcard: VCardArray = [ + "vcard", + [ + ["fn", {}, "text", "John Doe"], + ["org", {}, "text", ["ACME Corp", "Engineering", "Backend"]], + ], + ]; + + const result = parseVCard(vcard); + + expect(result.organizations).toEqual(["ACME Corp > Engineering > Backend"]); + }); + + it("extracts multiple emails", () => { + const vcard: VCardArray = [ + "vcard", + [ + ["fn", {}, "text", "John Doe"], + ["email", {}, "text", "john@work.com"], + ["email", {}, "text", "john@personal.com"], + ], + ]; + + const result = parseVCard(vcard); + + expect(result.emails).toEqual([ + { address: "john@work.com" }, + { address: "john@personal.com" }, + ]); + }); + + it("extracts multiple phones", () => { + const vcard: VCardArray = [ + "vcard", + [ + ["fn", {}, "text", "John Doe"], + ["tel", {}, "text", "+1-555-0100"], + ["tel", {}, "text", "+1-555-0200"], + ], + ]; + + const result = parseVCard(vcard); + + expect(result.phones).toEqual([{ number: "+1-555-0100" }, { number: "+1-555-0200" }]); + }); + + it("extracts structured address", () => { + const vcard: VCardArray = [ + "vcard", + [ + ["fn", {}, "text", "John Doe"], + ["adr", {}, "text", ["", "", "123 Main St", "Springfield", "IL", "62701", "USA"]], + ], + ]; + + const result = parseVCard(vcard); + + expect(result.addresses).toEqual([ + { + text: "123 Main St, Springfield, IL, 62701, USA", + }, + ]); + }); + + it("extracts URLs", () => { + const vcard: VCardArray = [ + "vcard", + [ + ["fn", {}, "text", "John Doe"], + ["url", {}, "text", "https://example.com"], + ["url", {}, "text", "https://blog.example.com"], + ], + ]; + + const result = parseVCard(vcard); + + expect(result.links).toEqual([ + { uri: "https://example.com" }, + { uri: "https://blog.example.com" }, + ]); + }); + + it("extracts title and role separately", () => { + const vcard: VCardArray = [ + "vcard", + [ + ["fn", {}, "text", "John Doe"], + ["title", {}, "text", "Software Engineer"], + ["role", {}, "text", "Tech Lead"], + ], + ]; + + const result = parseVCard(vcard); + + expect(result.titles).toEqual([ + { name: "Software Engineer", kind: "title" }, + { name: "Tech Lead", kind: "role" }, + ]); + }); + + it("handles complete vCard with all fields", () => { + const vcard: VCardArray = [ + "vcard", + [ + ["fn", {}, "text", "Jane Smith"], + ["org", {}, "text", "Tech Corp"], + ["title", {}, "text", "CTO"], + ["email", {}, "text", "jane@tech.com"], + ["tel", {}, "text", "+1-555-0300"], + ["adr", {}, "text", ["", "", "456 Oak Ave", "Boston", "MA", "02101", "USA"]], + ["url", {}, "text", "https://jane.tech"], + ], + ]; + + const result = parseVCard(vcard); + + expect(result).toEqual({ + name: "Jane Smith", + organizations: ["Tech Corp"], + titles: [{ name: "CTO", kind: "title" }], + emails: [{ address: "jane@tech.com" }], + phones: [{ number: "+1-555-0300" }], + addresses: [{ text: "456 Oak Ave, Boston, MA, 02101, USA" }], + links: [{ uri: "https://jane.tech" }], + }); + }); + + it("ignores malformed properties", () => { + const vcard: VCardArray = [ + "vcard", + [ + ["fn", {}, "text", "John Doe"], + ["invalid"], // Too short + null, // Not an array + ["email"], // Missing required fields + ], + ]; + + const result = parseVCard(vcard); + + expect(result.name).toBe("John Doe"); + expect(result.emails).toBeUndefined(); + }); +}); diff --git a/src/rdap/components/ContactDisplay.tsx b/src/rdap/components/ContactDisplay.tsx index 95bb165..8407bdb 100644 --- a/src/rdap/components/ContactDisplay.tsx +++ b/src/rdap/components/ContactDisplay.tsx @@ -1,8 +1,8 @@ import type { FunctionComponent } from "react"; import React from "react"; import type { Entity } from "@/rdap/schemas"; -import VCardDisplay from "./VCardDisplay"; -import JSContactDisplay from "./JSContactDisplay"; +import VCardDisplay from "@/rdap/components/VCardDisplay"; +import JSContactDisplay from "@/rdap/components/JSContactDisplay"; export type ContactDisplayProps = { entity: Entity; diff --git a/src/rdap/components/ContactTable.tsx b/src/rdap/components/ContactTable.tsx new file mode 100644 index 0000000..852b086 --- /dev/null +++ b/src/rdap/components/ContactTable.tsx @@ -0,0 +1,219 @@ +import type { FunctionComponent } from "react"; +import React from "react"; +import type { ParsedContact } from "@/rdap/contact-parser"; +import { Table, Flex, Code, Link, Text, Badge } from "@radix-ui/themes"; +import CopyButton from "@/components/CopyButton"; + +export type ContactTableProps = { + contact: ParsedContact; +}; + +/** + * Renders contact information in a consistent table format. + * Accepts normalized contact data from either JSContact or vCard parsers. + * Memoized to prevent re-renders when contact data hasn't changed. + */ +const ContactTable: FunctionComponent = ({ contact }) => { + return ( + + + {/* Name */} + {contact.name && ( + + Name + + + {contact.name} + + + + + )} + + {/* Kind (JSContact only) */} + {contact.kind && ( + + Kind + + {contact.kind} + + + )} + + {/* Organizations */} + {contact.organizations && contact.organizations.length > 0 && ( + + Organization + + + {contact.organizations.map((org, index) => ( + + {org} + + + ))} + + + + )} + + {/* Titles/Roles */} + {contact.titles && contact.titles.length > 0 && ( + + Title/Role + + + {contact.titles.map((title, index) => ( + + {title.name} + {title.kind && title.kind !== "title" && ( + + {title.kind} + + )} + + ))} + + + + )} + + {/* Emails */} + {contact.emails && contact.emails.length > 0 && ( + + Email + + + {contact.emails.map((email, index) => ( + + + {email.address} + + {email.label && ( + + {email.label} + + )} + {email.contexts && email.contexts.length > 0 && ( + + {email.contexts.join(", ")} + + )} + + + ))} + + + + )} + + {/* Phones */} + {contact.phones && contact.phones.length > 0 && ( + + Phone + + + {contact.phones.map((phone, index) => ( + + + {phone.number} + + {phone.features && phone.features.length > 0 && ( + + {phone.features.join(", ")} + + )} + {phone.label && ( + + {phone.label} + + )} + + + ))} + + + + )} + + {/* Addresses */} + {contact.addresses && contact.addresses.length > 0 && ( + + Address + + + {contact.addresses.map((addr, index) => ( + + {addr.text} + {addr.details && addr.details.length > 0 && ( + + {addr.details.map((detail, idx) => ( + + {detail} + + ))} + + )} + + ))} + + + + )} + + {/* Online Services (JSContact only) */} + {contact.onlineServices && contact.onlineServices.length > 0 && ( + + Online + + + {contact.onlineServices.map((service, index) => ( + + {service.uri ? ( + + {service.text} + + ) : ( + {service.text} + )} + {service.service && ( + + {service.service} + + )} + {service.uri && } + + ))} + + + + )} + + {/* Links */} + {contact.links && contact.links.length > 0 && ( + + Links + + + {contact.links.map((link, index) => ( + + + {link.label || link.uri} + + + + ))} + + + + )} + + + ); +}; + +export default React.memo(ContactTable); diff --git a/src/rdap/components/EntitiesSection.tsx b/src/rdap/components/EntitiesSection.tsx index 4b12926..815a37a 100644 --- a/src/rdap/components/EntitiesSection.tsx +++ b/src/rdap/components/EntitiesSection.tsx @@ -1,7 +1,7 @@ import type { FunctionComponent } from "react"; import React from "react"; import type { Entity, RdapStatusType } from "@/rdap/schemas"; -import { Box, Flex, Badge, Text, Code, DataList, Table } from "@radix-ui/themes"; +import { Box, Flex, Badge, Code, DataList, Table } from "@radix-ui/themes"; import ContactDisplay from "@/rdap/components/ContactDisplay"; import CopyButton from "@/components/CopyButton"; import StatusBadge from "@/components/StatusBadge"; @@ -82,6 +82,18 @@ const EntitiesSection: FunctionComponent = ({ entities }) )} + + {entity.port43 && ( + + WHOIS Server + + + {entity.port43} + + + + + )} {(entity.vcardArray || entity.jscard) && ( @@ -126,16 +138,6 @@ const EntitiesSection: FunctionComponent = ({ entities }) )} - - {entity.port43 && ( - - - WHOIS Server: - - {entity.port43} - - - )} ); diff --git a/src/rdap/components/JSContactDisplay.tsx b/src/rdap/components/JSContactDisplay.tsx index 4523c57..8105b8f 100644 --- a/src/rdap/components/JSContactDisplay.tsx +++ b/src/rdap/components/JSContactDisplay.tsx @@ -1,8 +1,8 @@ import type { FunctionComponent } from "react"; -import React from "react"; +import React, { useMemo } from "react"; import type { JSCard } from "@/rdap/schemas"; -import { Table, Flex, Code, Link, Text, Badge } from "@radix-ui/themes"; -import CopyButton from "@/components/CopyButton"; +import { parseJSContact } from "@/rdap/contact-parser"; +import ContactTable from "@/rdap/components/ContactTable"; export type JSContactDisplayProps = { jscard: JSCard; @@ -13,325 +13,8 @@ export type JSContactDisplayProps = { * JSContact is a JSON-native alternative to vCard/jCard. */ const JSContactDisplay: FunctionComponent = ({ jscard }) => { - // Extract full name or build from components - const displayName = jscard.name?.full || ""; - - // Extract organization names - const organizations = jscard.organizations - ? Object.values(jscard.organizations) - .map((org) => { - if (org.name && org.units) { - return `${org.name} > ${org.units.map((u: { name: string }) => u.name).join(" > ")}`; - } - return ( - org.name || - org.units?.map((u: { name: string }) => u.name).join(" > ") || - "" - ); - }) - .filter(Boolean) - : []; - - // Extract titles/roles - cast to proper type - const titles = jscard.titles - ? (Object.values(jscard.titles) as Array<{ - name: string; - kind?: "title" | "role"; - organizationId?: string; - }>) - : []; - - // Extract emails - cast to proper type - const emails = jscard.emails - ? (Object.entries(jscard.emails) as Array< - [ - string, - { - address: string; - contexts?: Record; - pref?: number; - label?: string; - }, - ] - >) - : []; - - // Extract phones - cast to proper type - const phones = jscard.phones - ? (Object.entries(jscard.phones) as Array< - [ - string, - { - number: string; - features?: Record; - contexts?: Record; - pref?: number; - label?: string; - }, - ] - >) - : []; - - // Extract addresses - cast to proper type - const addresses = jscard.addresses - ? (Object.entries(jscard.addresses) as Array< - [ - string, - { - components?: Array<{ kind?: string; value: string }>; - full?: string; - countryCode?: string; - coordinates?: string; - timeZone?: string; - contexts?: Record; - pref?: number; - }, - ] - >) - : []; - - // Extract online services - cast to proper type - const onlineServices = jscard.onlineServices - ? (Object.entries(jscard.onlineServices) as Array< - [ - string, - { - service?: string; - uri?: string; - user?: string; - contexts?: Record; - pref?: number; - label?: string; - }, - ] - >) - : []; - - // Extract links - cast to proper type - const links = jscard.links - ? (Object.entries(jscard.links) as Array< - [ - string, - { - uri: string; - contexts?: Record; - pref?: number; - label?: string; - }, - ] - >) - : []; - - return ( - - - {displayName && ( - - Name - - - {displayName} - - - - - )} - - {jscard.kind && jscard.kind !== "individual" && ( - - Kind - - {jscard.kind} - - - )} - - {organizations.length > 0 && ( - - Organization - - - {organizations.map((org, index) => ( - - {org} - - - ))} - - - - )} - - {titles.length > 0 && ( - - Title/Role - - - {titles.map((title, index) => ( - - {title.name} - {title.kind && title.kind !== "title" && ( - - {title.kind} - - )} - - ))} - - - - )} - - {emails.length > 0 && ( - - Email - - - {emails.map(([key, email]) => ( - - - {email.address} - - {email.label && ( - - {email.label} - - )} - {email.contexts && - Object.keys(email.contexts).length > 0 && ( - - {Object.keys(email.contexts).join(", ")} - - )} - - - ))} - - - - )} - - {phones.length > 0 && ( - - Phone - - - {phones.map(([key, phone]) => ( - - - {phone.number} - - {phone.features && - Object.keys(phone.features).length > 0 && ( - - {Object.keys(phone.features).join(", ")} - - )} - {phone.label && ( - - {phone.label} - - )} - - - ))} - - - - )} - - {addresses.length > 0 && ( - - Address - - - {addresses.map(([key, addr]) => { - // Use full address if available, otherwise build from components - const addressText = - addr.full || - (addr.components - ? addr.components.map((c) => c.value).join(", ") - : ""); - const details = [addr.countryCode, addr.timeZone].filter( - Boolean - ); - - return ( - - {addressText} - {details.length > 0 && ( - - {details.map((detail, idx) => ( - - {detail} - - ))} - - )} - - ); - })} - - - - )} - - {onlineServices.length > 0 && ( - - Online - - - {onlineServices.map(([key, service]) => ( - - {service.uri && ( - - - {service.service || service.user || service.uri} - - - )} - {!service.uri && ( - - {service.service || service.user || key} - - )} - {service.service && ( - - {service.service} - - )} - {service.uri && } - - ))} - - - - )} - - {links.length > 0 && ( - - Links - - - {links.map(([key, link]) => ( - - - {link.label || link.uri} - - - - ))} - - - - )} - - - ); + const contact = useMemo(() => parseJSContact(jscard), [jscard]); + return ; }; export default JSContactDisplay; diff --git a/src/rdap/components/VCardDisplay.tsx b/src/rdap/components/VCardDisplay.tsx index 78368c6..84071ef 100644 --- a/src/rdap/components/VCardDisplay.tsx +++ b/src/rdap/components/VCardDisplay.tsx @@ -1,239 +1,20 @@ import type { FunctionComponent } from "react"; -import React from "react"; +import React, { useMemo } from "react"; import type { VCardArray } from "@/rdap/schemas"; -import { Table, Flex, Code, Link, Text } from "@radix-ui/themes"; -import CopyButton from "@/components/CopyButton"; +import { parseVCard } from "@/rdap/contact-parser"; +import ContactTable from "@/rdap/components/ContactTable"; export type VCardDisplayProps = { vcardArray: VCardArray; }; -type VCardProperty = [string, Record, string, string]; - -type StructuredName = { - family: string; - given: string; - additional: string; - prefix: string; - suffix: string; -}; - -type Address = { - street: string; - locality: string; - region: string; - postal: string; - country: string; -}; - -type ParsedVCard = { - fn?: string; - name?: StructuredName; - org?: string; - emails?: string[]; - phones?: string[]; - addresses?: Address[]; - urls?: string[]; - title?: string; - role?: string; -}; - /** - * Parses a vCard (jCard) array and extracts common contact properties. - * jCard format: ["vcard", [[prop_name, params, value_type, value], ...]] + * Display component for vCard (jCard format, RFC 7095) contact cards in RDAP responses. + * jCard is the JSON representation of vCard 4.0. */ -const parseVCard = (vcardArray: VCardArray): ParsedVCard => { - const [, properties] = vcardArray; - const result: ParsedVCard = {}; - - properties.forEach((prop: VCardProperty) => { - if (!Array.isArray(prop) || prop.length < 4) return; - - const [name, , , value] = prop as VCardProperty; - const nameLower = name.toLowerCase(); - - switch (nameLower) { - case "fn": // Formatted name - result.fn = value; - break; - case "n": // Structured name [family, given, additional, prefix, suffix] - if (Array.isArray(value)) { - result.name = { - family: value[0], - given: value[1], - additional: value[2], - prefix: value[3], - suffix: value[4], - }; - } - break; - case "org": // Organization - result.org = Array.isArray(value) ? value.join(" > ") : value; - break; - case "email": - if (!result.emails) result.emails = []; - result.emails.push(value); - break; - case "tel": // Telephone - if (!result.phones) result.phones = []; - result.phones.push(value); - break; - case "adr": // Address [PO, extended, street, locality, region, postal, country] - if (Array.isArray(value)) { - const address = { - street: value[2], - locality: value[3], - region: value[4], - postal: value[5], - country: value[6], - }; - if (!result.addresses) result.addresses = []; - result.addresses.push(address); - } - break; - case "url": - if (!result.urls) result.urls = []; - result.urls.push(value); - break; - case "title": - result.title = value; - break; - case "role": - result.role = value; - break; - } - }); - - return result; -}; - const VCardDisplay: FunctionComponent = ({ vcardArray }) => { - const vcard = parseVCard(vcardArray); - - return ( - - - {vcard.fn && ( - - Name - - - {vcard.fn} - - - - - )} - - {vcard.org && ( - - Organization - - - {vcard.org} - - - - - )} - - {vcard.title && ( - - Title - - {vcard.title} - - - )} - - {vcard.role && ( - - Role - - {vcard.role} - - - )} - - {vcard.emails && vcard.emails.length > 0 && ( - - Email - - - {vcard.emails.map((email: string, index: number) => ( - - - {email} - - - - ))} - - - - )} - - {vcard.phones && vcard.phones.length > 0 && ( - - Phone - - - {vcard.phones.map((phone: string, index: number) => ( - - - {phone} - - - - ))} - - - - )} - - {vcard.addresses && vcard.addresses.length > 0 && ( - - Address - - - {vcard.addresses.map((addr: Address, index: number) => ( - - {[ - addr.street, - addr.locality, - addr.region, - addr.postal, - addr.country, - ] - .filter(Boolean) - .join(", ")} - - ))} - - - - )} - - {vcard.urls && vcard.urls.length > 0 && ( - - URL - - - {vcard.urls.map((url: string, index: number) => ( - - - {url} - - - - ))} - - - - )} - - - ); + const contact = useMemo(() => parseVCard(vcardArray), [vcardArray]); + return ; }; export default VCardDisplay; diff --git a/src/rdap/contact-parser.ts b/src/rdap/contact-parser.ts new file mode 100644 index 0000000..9dbee68 --- /dev/null +++ b/src/rdap/contact-parser.ts @@ -0,0 +1,241 @@ +import type { JSCard, VCardArray } from "@/rdap/schemas"; + +/** + * Unified contact data structure that normalizes both JSContact (RFC 9553) + * and vCard (RFC 6350) formats into a consistent interface for display. + */ +export interface ParsedContact { + /** Full display name of the contact */ + name?: string; + + /** Type of entity (individual, org, group, etc.) - JSContact only */ + kind?: string; + + /** List of organizations, with units joined by " > " */ + organizations?: string[]; + + /** List of titles/roles */ + titles?: Array<{ + name: string; + kind?: "title" | "role"; + }>; + + /** Email addresses with optional metadata */ + emails?: Array<{ + address: string; + label?: string; + contexts?: string[]; + }>; + + /** Phone numbers with optional metadata */ + phones?: Array<{ + number: string; + features?: string[]; + label?: string; + }>; + + /** Physical addresses */ + addresses?: Array<{ + text: string; + details?: string[]; + }>; + + /** Online services (JSContact only) */ + onlineServices?: Array<{ + text: string; + uri?: string; + service?: string; + }>; + + /** Web links/URLs */ + links?: Array<{ + uri: string; + label?: string; + }>; +} + +/** + * Parses a JSContact (RFC 9553) card into the unified ParsedContact format. + * JSContact is the modern JSON-native alternative to vCard. + */ +export function parseJSContact(jscard: JSCard): ParsedContact { + const contact: ParsedContact = {}; + + // Extract display name + if (jscard.name?.full) { + contact.name = jscard.name.full; + } + + // Extract kind (if not default "individual") + if (jscard.kind && jscard.kind !== "individual") { + contact.kind = jscard.kind; + } + + // Extract organizations + if (jscard.organizations) { + contact.organizations = Object.values(jscard.organizations) + .map((org) => { + if (org.name && org.units) { + return `${org.name} > ${org.units.map((u) => u.name).join(" > ")}`; + } + return org.name || org.units?.map((u) => u.name).join(" > ") || ""; + }) + .filter(Boolean); + } + + // Extract titles/roles + if (jscard.titles) { + contact.titles = Object.values(jscard.titles).map((title) => ({ + name: title.name, + kind: title.kind, + })); + } + + // Extract emails + if (jscard.emails) { + contact.emails = Object.values(jscard.emails).map((email) => ({ + address: email.address, + label: email.label, + contexts: email.contexts ? Object.keys(email.contexts) : undefined, + })); + } + + // Extract phones + if (jscard.phones) { + contact.phones = Object.values(jscard.phones).map((phone) => ({ + number: phone.number, + features: phone.features ? Object.keys(phone.features) : undefined, + label: phone.label, + })); + } + + // Extract addresses + if (jscard.addresses) { + contact.addresses = Object.values(jscard.addresses).map((addr) => { + const text = addr.full || addr.components?.map((c) => c.value).join(", ") || ""; + const details = [addr.countryCode, addr.timeZone].filter(Boolean) as string[]; + + return { + text, + details: details.length > 0 ? details : undefined, + }; + }); + } + + // Extract online services + if (jscard.onlineServices) { + contact.onlineServices = Object.values(jscard.onlineServices).map((service) => ({ + text: service.service || service.user || service.uri || "", + uri: service.uri, + service: service.service, + })); + } + + // Extract links + if (jscard.links) { + contact.links = Object.values(jscard.links).map((link) => ({ + uri: link.uri, + label: link.label, + })); + } + + return contact; +} + +/** + * Parses a vCard (jCard format, RFC 7095) into the unified ParsedContact format. + * jCard is the JSON representation of vCard 4.0. + */ +export function parseVCard(vcardArray: VCardArray): ParsedContact { + // Type for vCard property: [name, params, type, value] + type VCardProperty = [string, Record, string, unknown]; + + const [, properties] = vcardArray; + const contact: ParsedContact = { + emails: [], + phones: [], + addresses: [], + links: [], + titles: [], + }; + + properties.forEach((prop: unknown) => { + if (!Array.isArray(prop) || prop.length < 4) return; + + const [name, , , value] = prop as VCardProperty; + const nameLower = name.toLowerCase(); + + switch (nameLower) { + case "fn": // Formatted name + if (typeof value === "string") { + contact.name = value; + } + break; + + case "org": // Organization + if (Array.isArray(value)) { + contact.organizations = [value.join(" > ")]; + } else if (typeof value === "string") { + contact.organizations = [value]; + } + break; + + case "email": + if (typeof value === "string" && contact.emails) { + contact.emails.push({ address: value }); + } + break; + + case "tel": // Telephone + if (typeof value === "string" && contact.phones) { + contact.phones.push({ number: value }); + } + break; + + case "adr": // Address [PO, extended, street, locality, region, postal, country] + if (Array.isArray(value) && contact.addresses) { + const addressParts = [ + value[2], // street + value[3], // locality + value[4], // region + value[5], // postal + value[6], // country + ].filter(Boolean); + + if (addressParts.length > 0) { + contact.addresses.push({ + text: addressParts.join(", "), + }); + } + } + break; + + case "url": + if (typeof value === "string" && contact.links) { + contact.links.push({ uri: value }); + } + break; + + case "title": + if (typeof value === "string" && contact.titles) { + contact.titles.push({ name: value, kind: "title" }); + } + break; + + case "role": + if (typeof value === "string" && contact.titles) { + contact.titles.push({ name: value, kind: "role" }); + } + break; + } + }); + + // Clean up empty arrays + if (contact.emails?.length === 0) delete contact.emails; + if (contact.phones?.length === 0) delete contact.phones; + if (contact.addresses?.length === 0) delete contact.addresses; + if (contact.links?.length === 0) delete contact.links; + if (contact.titles?.length === 0) delete contact.titles; + + return contact; +}