mirror of
https://github.com/Xevion/rdap.git
synced 2025-12-06 05:16:08 -06:00
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
This commit is contained in:
@@ -14,7 +14,7 @@ type AbstractCardProps = {
|
|||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
/** RDAP response data for download/display. When provided, enables JSON actions. */
|
/** RDAP response data for download/display. When provided, enables JSON actions. */
|
||||||
data?: ParsedGeneric | object;
|
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;
|
url?: string;
|
||||||
/** Query execution timestamp for filename generation */
|
/** Query execution timestamp for filename generation */
|
||||||
queryTimestamp?: Date;
|
queryTimestamp?: Date;
|
||||||
@@ -85,7 +85,7 @@ const AbstractCard: FunctionComponent<AbstractCardProps> = ({
|
|||||||
</Flex>
|
</Flex>
|
||||||
<Flex gap="2" align="center">
|
<Flex gap="2" align="center">
|
||||||
{url != null && (
|
{url != null && (
|
||||||
<Tooltip content="Open in new tab">
|
<Tooltip content="Open RDAP URL">
|
||||||
<IconButton variant="ghost" size="2" asChild>
|
<IconButton variant="ghost" size="2" asChild>
|
||||||
<a
|
<a
|
||||||
href={url}
|
href={url}
|
||||||
|
|||||||
@@ -40,8 +40,11 @@ const DynamicDate: FunctionComponent<DynamicDateProps> = ({
|
|||||||
: format(date, absoluteFormatString);
|
: format(date, absoluteFormatString);
|
||||||
const isoString = date.toISOString();
|
const isoString = date.toISOString();
|
||||||
|
|
||||||
|
// Relative time element (disable time since it's already shown in the tooltip)
|
||||||
|
const relative = <TimeAgo title="" date={date} />;
|
||||||
|
|
||||||
// Get display value based on global format
|
// Get display value based on global format
|
||||||
const displayValue = dateFormat === "relative" ? <TimeAgo date={date} /> : absoluteWithTz;
|
const displayValue = dateFormat === "relative" ? relative : absoluteWithTz;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -49,7 +52,7 @@ const DynamicDate: FunctionComponent<DynamicDateProps> = ({
|
|||||||
<Box style={{ minWidth: "280px" }} as="span">
|
<Box style={{ minWidth: "280px" }} as="span">
|
||||||
<Flex align="center" justify="between" mb="2" as="span">
|
<Flex align="center" justify="between" mb="2" as="span">
|
||||||
<Text size="1">
|
<Text size="1">
|
||||||
<strong>Relative:</strong> <TimeAgo date={date} />
|
<strong>Relative:</strong> {relative}
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex align="center" justify="between" mb="2" as="span">
|
<Flex align="center" justify="between" mb="2" as="span">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import { Link2Icon } from "@radix-ui/react-icons";
|
import { Link2Icon } from "@radix-ui/react-icons";
|
||||||
import CopyButton, { type CopyButtonProps } from "./CopyButton";
|
import CopyButton, { type CopyButtonProps } from "@/components/CopyButton";
|
||||||
|
|
||||||
export type ShareButtonProps = Omit<CopyButtonProps, "value"> & {
|
export type ShareButtonProps = Omit<CopyButtonProps, "value"> & {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
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("URL Utilities", () => {
|
||||||
describe("serializeQueryToUrl", () => {
|
describe("serializeQueryToUrl", () => {
|
||||||
|
|||||||
486
src/rdap/__tests__/contact-parser.test.ts
Normal file
486
src/rdap/__tests__/contact-parser.test.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { Entity } from "@/rdap/schemas";
|
import type { Entity } from "@/rdap/schemas";
|
||||||
import VCardDisplay from "./VCardDisplay";
|
import VCardDisplay from "@/rdap/components/VCardDisplay";
|
||||||
import JSContactDisplay from "./JSContactDisplay";
|
import JSContactDisplay from "@/rdap/components/JSContactDisplay";
|
||||||
|
|
||||||
export type ContactDisplayProps = {
|
export type ContactDisplayProps = {
|
||||||
entity: Entity;
|
entity: Entity;
|
||||||
|
|||||||
219
src/rdap/components/ContactTable.tsx
Normal file
219
src/rdap/components/ContactTable.tsx
Normal file
@@ -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<ContactTableProps> = ({ contact }) => {
|
||||||
|
return (
|
||||||
|
<Table.Root size="2" variant="surface">
|
||||||
|
<Table.Body>
|
||||||
|
{/* Name */}
|
||||||
|
{contact.name && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Name</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex gap="2" align="center">
|
||||||
|
<Text>{contact.name}</Text>
|
||||||
|
<CopyButton value={contact.name} />
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kind (JSContact only) */}
|
||||||
|
{contact.kind && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Kind</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Badge variant="soft">{contact.kind}</Badge>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Organizations */}
|
||||||
|
{contact.organizations && contact.organizations.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Organization</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{contact.organizations.map((org, index) => (
|
||||||
|
<Flex key={index} align="center" gap="2">
|
||||||
|
<Code variant="ghost">{org}</Code>
|
||||||
|
<CopyButton value={org} />
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Titles/Roles */}
|
||||||
|
{contact.titles && contact.titles.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Title/Role</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{contact.titles.map((title, index) => (
|
||||||
|
<Flex key={index} align="center" gap="2">
|
||||||
|
<Text>{title.name}</Text>
|
||||||
|
{title.kind && title.kind !== "title" && (
|
||||||
|
<Badge size="1" variant="soft">
|
||||||
|
{title.kind}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Emails */}
|
||||||
|
{contact.emails && contact.emails.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Email</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{contact.emails.map((email, index) => (
|
||||||
|
<Flex key={index} align="center" gap="2">
|
||||||
|
<Link href={`mailto:${email.address}`}>
|
||||||
|
<Code variant="ghost">{email.address}</Code>
|
||||||
|
</Link>
|
||||||
|
{email.label && (
|
||||||
|
<Badge size="1" variant="soft">
|
||||||
|
{email.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{email.contexts && email.contexts.length > 0 && (
|
||||||
|
<Badge size="1" variant="outline">
|
||||||
|
{email.contexts.join(", ")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<CopyButton value={email.address} />
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Phones */}
|
||||||
|
{contact.phones && contact.phones.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Phone</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{contact.phones.map((phone, index) => (
|
||||||
|
<Flex key={index} align="center" gap="2">
|
||||||
|
<Link href={`tel:${phone.number}`}>
|
||||||
|
<Code variant="ghost">{phone.number}</Code>
|
||||||
|
</Link>
|
||||||
|
{phone.features && phone.features.length > 0 && (
|
||||||
|
<Badge size="1" variant="soft">
|
||||||
|
{phone.features.join(", ")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{phone.label && (
|
||||||
|
<Badge size="1" variant="outline">
|
||||||
|
{phone.label}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<CopyButton value={phone.number} />
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Addresses */}
|
||||||
|
{contact.addresses && contact.addresses.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Address</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="2">
|
||||||
|
{contact.addresses.map((addr, index) => (
|
||||||
|
<Flex key={index} direction="column" gap="1">
|
||||||
|
<Text size="2">{addr.text}</Text>
|
||||||
|
{addr.details && addr.details.length > 0 && (
|
||||||
|
<Flex gap="1">
|
||||||
|
{addr.details.map((detail, idx) => (
|
||||||
|
<Badge key={idx} size="1" variant="soft">
|
||||||
|
{detail}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Online Services (JSContact only) */}
|
||||||
|
{contact.onlineServices && contact.onlineServices.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Online</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{contact.onlineServices.map((service, index) => (
|
||||||
|
<Flex key={index} align="center" gap="2">
|
||||||
|
{service.uri ? (
|
||||||
|
<Link
|
||||||
|
href={service.uri}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<Code variant="ghost">{service.text}</Code>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Code variant="ghost">{service.text}</Code>
|
||||||
|
)}
|
||||||
|
{service.service && (
|
||||||
|
<Badge size="1" variant="soft">
|
||||||
|
{service.service}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{service.uri && <CopyButton value={service.uri} />}
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Links */}
|
||||||
|
{contact.links && contact.links.length > 0 && (
|
||||||
|
<Table.Row>
|
||||||
|
<Table.RowHeaderCell>Links</Table.RowHeaderCell>
|
||||||
|
<Table.Cell>
|
||||||
|
<Flex direction="column" gap="1">
|
||||||
|
{contact.links.map((link, index) => (
|
||||||
|
<Flex key={index} align="center" gap="2">
|
||||||
|
<Link href={link.uri} target="_blank" rel="noreferrer">
|
||||||
|
<Code variant="ghost">{link.label || link.uri}</Code>
|
||||||
|
</Link>
|
||||||
|
<CopyButton value={link.uri} />
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
)}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ContactTable);
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import type { Entity, RdapStatusType } from "@/rdap/schemas";
|
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 ContactDisplay from "@/rdap/components/ContactDisplay";
|
||||||
import CopyButton from "@/components/CopyButton";
|
import CopyButton from "@/components/CopyButton";
|
||||||
import StatusBadge from "@/components/StatusBadge";
|
import StatusBadge from "@/components/StatusBadge";
|
||||||
@@ -82,6 +82,18 @@ const EntitiesSection: FunctionComponent<EntitiesSectionProps> = ({ entities })
|
|||||||
</DataList.Value>
|
</DataList.Value>
|
||||||
</DataList.Item>
|
</DataList.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{entity.port43 && (
|
||||||
|
<DataList.Item>
|
||||||
|
<DataList.Label>WHOIS Server</DataList.Label>
|
||||||
|
<DataList.Value>
|
||||||
|
<Flex align="center" gap="2">
|
||||||
|
<Code variant="ghost">{entity.port43}</Code>
|
||||||
|
<CopyButton value={entity.port43} />
|
||||||
|
</Flex>
|
||||||
|
</DataList.Value>
|
||||||
|
</DataList.Item>
|
||||||
|
)}
|
||||||
</DataList.Root>
|
</DataList.Root>
|
||||||
|
|
||||||
{(entity.vcardArray || entity.jscard) && (
|
{(entity.vcardArray || entity.jscard) && (
|
||||||
@@ -126,16 +138,6 @@ const EntitiesSection: FunctionComponent<EntitiesSectionProps> = ({ entities })
|
|||||||
</Table.Body>
|
</Table.Body>
|
||||||
</Table.Root>
|
</Table.Root>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{entity.port43 && (
|
|
||||||
<Flex align="center" gap="2">
|
|
||||||
<Text size="2" weight="medium">
|
|
||||||
WHOIS Server:
|
|
||||||
</Text>
|
|
||||||
<Code variant="ghost">{entity.port43}</Code>
|
|
||||||
<CopyButton value={entity.port43} />
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import type { JSCard } from "@/rdap/schemas";
|
import type { JSCard } from "@/rdap/schemas";
|
||||||
import { Table, Flex, Code, Link, Text, Badge } from "@radix-ui/themes";
|
import { parseJSContact } from "@/rdap/contact-parser";
|
||||||
import CopyButton from "@/components/CopyButton";
|
import ContactTable from "@/rdap/components/ContactTable";
|
||||||
|
|
||||||
export type JSContactDisplayProps = {
|
export type JSContactDisplayProps = {
|
||||||
jscard: JSCard;
|
jscard: JSCard;
|
||||||
@@ -13,325 +13,8 @@ export type JSContactDisplayProps = {
|
|||||||
* JSContact is a JSON-native alternative to vCard/jCard.
|
* JSContact is a JSON-native alternative to vCard/jCard.
|
||||||
*/
|
*/
|
||||||
const JSContactDisplay: FunctionComponent<JSContactDisplayProps> = ({ jscard }) => {
|
const JSContactDisplay: FunctionComponent<JSContactDisplayProps> = ({ jscard }) => {
|
||||||
// Extract full name or build from components
|
const contact = useMemo(() => parseJSContact(jscard), [jscard]);
|
||||||
const displayName = jscard.name?.full || "";
|
return <ContactTable contact={contact} />;
|
||||||
|
|
||||||
// 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<string, boolean>;
|
|
||||||
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<string, boolean>;
|
|
||||||
contexts?: Record<string, boolean>;
|
|
||||||
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<string, boolean>;
|
|
||||||
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<string, boolean>;
|
|
||||||
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<string, boolean>;
|
|
||||||
pref?: number;
|
|
||||||
label?: string;
|
|
||||||
},
|
|
||||||
]
|
|
||||||
>)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Table.Root size="2" variant="surface">
|
|
||||||
<Table.Body>
|
|
||||||
{displayName && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Name</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex gap="2" align="center">
|
|
||||||
<Text>{displayName}</Text>
|
|
||||||
<CopyButton value={displayName} />
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{jscard.kind && jscard.kind !== "individual" && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Kind</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Badge variant="soft">{jscard.kind}</Badge>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{organizations.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Organization</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
{organizations.map((org, index) => (
|
|
||||||
<Flex key={index} align="center" gap="2">
|
|
||||||
<Code variant="ghost">{org}</Code>
|
|
||||||
<CopyButton value={org} />
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{titles.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Title/Role</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
{titles.map((title, index) => (
|
|
||||||
<Flex key={index} align="center" gap="2">
|
|
||||||
<Text>{title.name}</Text>
|
|
||||||
{title.kind && title.kind !== "title" && (
|
|
||||||
<Badge size="1" variant="soft">
|
|
||||||
{title.kind}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{emails.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Email</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
{emails.map(([key, email]) => (
|
|
||||||
<Flex key={key} align="center" gap="2">
|
|
||||||
<Link href={`mailto:${email.address}`}>
|
|
||||||
<Code variant="ghost">{email.address}</Code>
|
|
||||||
</Link>
|
|
||||||
{email.label && (
|
|
||||||
<Badge size="1" variant="soft">
|
|
||||||
{email.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{email.contexts &&
|
|
||||||
Object.keys(email.contexts).length > 0 && (
|
|
||||||
<Badge size="1" variant="outline">
|
|
||||||
{Object.keys(email.contexts).join(", ")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<CopyButton value={email.address} />
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{phones.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Phone</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
{phones.map(([key, phone]) => (
|
|
||||||
<Flex key={key} align="center" gap="2">
|
|
||||||
<Link href={`tel:${phone.number}`}>
|
|
||||||
<Code variant="ghost">{phone.number}</Code>
|
|
||||||
</Link>
|
|
||||||
{phone.features &&
|
|
||||||
Object.keys(phone.features).length > 0 && (
|
|
||||||
<Badge size="1" variant="soft">
|
|
||||||
{Object.keys(phone.features).join(", ")}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{phone.label && (
|
|
||||||
<Badge size="1" variant="outline">
|
|
||||||
{phone.label}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<CopyButton value={phone.number} />
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{addresses.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Address</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="2">
|
|
||||||
{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 (
|
|
||||||
<Flex key={key} direction="column" gap="1">
|
|
||||||
<Text size="2">{addressText}</Text>
|
|
||||||
{details.length > 0 && (
|
|
||||||
<Flex gap="1">
|
|
||||||
{details.map((detail, idx) => (
|
|
||||||
<Badge key={idx} size="1" variant="soft">
|
|
||||||
{detail}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{onlineServices.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Online</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
{onlineServices.map(([key, service]) => (
|
|
||||||
<Flex key={key} align="center" gap="2">
|
|
||||||
{service.uri && (
|
|
||||||
<Link
|
|
||||||
href={service.uri}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
<Code variant="ghost">
|
|
||||||
{service.service || service.user || service.uri}
|
|
||||||
</Code>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
{!service.uri && (
|
|
||||||
<Code variant="ghost">
|
|
||||||
{service.service || service.user || key}
|
|
||||||
</Code>
|
|
||||||
)}
|
|
||||||
{service.service && (
|
|
||||||
<Badge size="1" variant="soft">
|
|
||||||
{service.service}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{service.uri && <CopyButton value={service.uri} />}
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{links.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Links</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
{links.map(([key, link]) => (
|
|
||||||
<Flex key={key} align="center" gap="2">
|
|
||||||
<Link href={link.uri} target="_blank" rel="noreferrer">
|
|
||||||
<Code variant="ghost">{link.label || link.uri}</Code>
|
|
||||||
</Link>
|
|
||||||
<CopyButton value={link.uri} />
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default JSContactDisplay;
|
export default JSContactDisplay;
|
||||||
|
|||||||
@@ -1,239 +1,20 @@
|
|||||||
import type { FunctionComponent } from "react";
|
import type { FunctionComponent } from "react";
|
||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import type { VCardArray } from "@/rdap/schemas";
|
import type { VCardArray } from "@/rdap/schemas";
|
||||||
import { Table, Flex, Code, Link, Text } from "@radix-ui/themes";
|
import { parseVCard } from "@/rdap/contact-parser";
|
||||||
import CopyButton from "@/components/CopyButton";
|
import ContactTable from "@/rdap/components/ContactTable";
|
||||||
|
|
||||||
export type VCardDisplayProps = {
|
export type VCardDisplayProps = {
|
||||||
vcardArray: VCardArray;
|
vcardArray: VCardArray;
|
||||||
};
|
};
|
||||||
|
|
||||||
type VCardProperty = [string, Record<string, string>, 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.
|
* Display component for vCard (jCard format, RFC 7095) contact cards in RDAP responses.
|
||||||
* jCard format: ["vcard", [[prop_name, params, value_type, value], ...]]
|
* 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<VCardDisplayProps> = ({ vcardArray }) => {
|
const VCardDisplay: FunctionComponent<VCardDisplayProps> = ({ vcardArray }) => {
|
||||||
const vcard = parseVCard(vcardArray);
|
const contact = useMemo(() => parseVCard(vcardArray), [vcardArray]);
|
||||||
|
return <ContactTable contact={contact} />;
|
||||||
return (
|
|
||||||
<Table.Root size="2" variant="surface">
|
|
||||||
<Table.Body>
|
|
||||||
{vcard.fn && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Name</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex gap="2" align="center">
|
|
||||||
<Text>{vcard.fn}</Text>
|
|
||||||
<CopyButton value={vcard.fn} />
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vcard.org && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Organization</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex align="center" gap="2">
|
|
||||||
<Code variant="ghost">{vcard.org}</Code>
|
|
||||||
<CopyButton value={vcard.org} />
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vcard.title && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Title</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Text>{vcard.title}</Text>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vcard.role && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Role</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Text>{vcard.role}</Text>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vcard.emails && vcard.emails.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Email</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
{vcard.emails.map((email: string, index: number) => (
|
|
||||||
<Flex key={index} align="center" gap="2">
|
|
||||||
<Link href={`mailto:${email}`}>
|
|
||||||
<Code variant="ghost">{email}</Code>
|
|
||||||
</Link>
|
|
||||||
<CopyButton value={email} />
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vcard.phones && vcard.phones.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Phone</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
{vcard.phones.map((phone: string, index: number) => (
|
|
||||||
<Flex key={index} align="center" gap="2">
|
|
||||||
<Link href={`tel:${phone}`}>
|
|
||||||
<Code variant="ghost">{phone}</Code>
|
|
||||||
</Link>
|
|
||||||
<CopyButton value={phone} />
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vcard.addresses && vcard.addresses.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>Address</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="2">
|
|
||||||
{vcard.addresses.map((addr: Address, index: number) => (
|
|
||||||
<Text key={index} size="2">
|
|
||||||
{[
|
|
||||||
addr.street,
|
|
||||||
addr.locality,
|
|
||||||
addr.region,
|
|
||||||
addr.postal,
|
|
||||||
addr.country,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ")}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{vcard.urls && vcard.urls.length > 0 && (
|
|
||||||
<Table.Row>
|
|
||||||
<Table.RowHeaderCell>URL</Table.RowHeaderCell>
|
|
||||||
<Table.Cell>
|
|
||||||
<Flex direction="column" gap="1">
|
|
||||||
{vcard.urls.map((url: string, index: number) => (
|
|
||||||
<Flex key={index} align="center" gap="2">
|
|
||||||
<Link href={url} target="_blank" rel="noreferrer">
|
|
||||||
<Code variant="ghost">{url}</Code>
|
|
||||||
</Link>
|
|
||||||
<CopyButton value={url} />
|
|
||||||
</Flex>
|
|
||||||
))}
|
|
||||||
</Flex>
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
)}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default VCardDisplay;
|
export default VCardDisplay;
|
||||||
|
|||||||
241
src/rdap/contact-parser.ts
Normal file
241
src/rdap/contact-parser.ts
Normal file
@@ -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>, 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user