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; +}