Release Contact Tools 0.2.0
Verify / verify (push) Canceled after 0s

This commit is contained in:
2026-09-02 11:45:06 +02:00
parent 991bebd0d9
commit eb7ff7ae8f
27 changed files with 1356 additions and 110 deletions
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
CI: "true"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
- name: Install browser engines
run: npx playwright install --with-deps chromium firefox webkit
- name: Browser tests
run: npm run test:browser
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added vCard 2.1, grouped-property, quoted-printable and Base64 support while
preserving unmapped properties and parameters.
- Added loss-preserving jCard and focused JSContact import/export with explicit
mapping evidence.
## 0.1.0 - 2026-09-01
- Added the initial local-first Contact Tools workbench.
+7 -4
View File
@@ -6,15 +6,18 @@ Contact Tools is a standalone local-first application in the [add·ideas Toolbox
## Version 0.1 scope
- Unfolding and bounded parsing of vCard 3.0 and 4.0 contact sets
- Unfolding and bounded parsing of vCard 2.1, 3.0 and 4.0 contact sets
- Editable core names, organizations, titles, emails, phones, addresses, URLs, birthdays, notes, UIDs and categories
- Canonical vCard 3.0/4.0 export with UTF-8-aware 75-octet line folding
- Canonical vCard 2.1/3.0/4.0 export with UTF-8-aware 75-octet line folding
- Quoted-printable and bounded Base64 decoding, grouped-property support, and loss-preserving retention of unmapped IANA/X properties and parameters
- Loss-preserving RFC 7095 jCard import/export and a focused JSContact
import/export adapter with unmapped-field evidence
- RFC 4180-style CSV import with inferred, reviewable column mapping and spreadsheet-formula-safe CSV export
- Explainable exact duplicate candidates and a conflict-reporting, left-biased merge preview
- Explicit, local vCard QR generation with a conservative payload bound and SVG download
- Masked overview fields, one-click memory clearing, and no persistence or telemetry
Text input is limited to 2 MiB, 50,000 physical lines and 2,000 contacts. Binary and quoted-printable properties are diagnosed but not decoded, and unsupported properties are reported rather than silently round-tripped. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
Text input is limited to 2 MiB, 50,000 physical lines and 2,000 contacts. Encoded values are limited to 64 KiB per property; malformed encodings are diagnosed while their raw values remain preserved. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development
@@ -28,7 +31,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/contact-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/contact-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Contact Tools 0.1.0 is available at:
The corresponding source for Contact Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/contact-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/contact-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices
Contact Tools 0.1.0 directly depends on these runtime packages:
Contact Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `qrcode-generator` | 2.0.4 | MIT |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
+1 -1
View File
@@ -2,7 +2,7 @@
Contact Tools is a static React/Vite application wrapped in the shared Toolbox shell. `contact/model.ts` owns bounded vCard unfolding/parsing, canonical serialization, RFC 4180-style CSV conversion, exact duplicate indexing and deterministic merge previews. The workbench only replaces its active records after a complete successful parse.
Imported properties are projected into a typed contact model and rendered through React text nodes and native controls. Unknown properties are diagnosed and omitted from canonical output instead of being injected back into markup. QR SVG is generated from UTF-8 bytes of the canonical vCard by the pinned `qrcode-generator` runtime, checked for active SVG constructs, then displayed as an encoded image URL rather than inserted as markup.
Imported properties are projected into a typed contact model and rendered through React text nodes and native controls. Quoted-printable and bounded Base64 values are decoded locally. Unmapped properties retain their group, name, parameters, raw value and optional decoded bytes, are diagnosed as non-editable, and are serialized back as inert vCard content. QR SVG is generated from UTF-8 bytes of the canonical vCard by the pinned `qrcode-generator` runtime, checked for active SVG constructs, then displayed as an encoded image URL rather than inserted as markup.
Version 0.1 operations are synchronous and bounded, so the app creates no worker and has no persistence or server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
+22 -23
View File
@@ -1,23 +1,23 @@
{
"name": "contact-tools",
"version": "0.1.0",
"version": "0.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "contact-tools",
"version": "0.1.0",
"version": "0.2.0",
"license": "GPL-3.0-or-later",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"qrcode-generator": "2.0.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
@@ -43,24 +43,24 @@
}
},
"node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3",
"license": "Apache-2.0",
"engines": {
"node": ">=20"
}
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0"
},
"node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0",
"license": "GPL-3.0-or-later",
"engines": {
"node": ">=22"
}
"version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later"
},
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
@@ -68,17 +68,16 @@
}
},
"node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3",
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3"
"@add-ideas/toolbox-contract": "0.3.0"
},
"bin": {
"toolbox-check": "dist/cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@adobe/css-tools": {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "contact-tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect, convert, and reconcile contacts locally in the browser.",
"license": "GPL-3.0-or-later",
"author": "Albrecht Degering",
@@ -39,15 +39,15 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.3",
"@add-ideas/toolbox-helpers": "0.1.0",
"@add-ideas/toolbox-shell-react": "0.2.3",
"@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.3.0",
"qrcode-generator": "2.0.4",
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3",
"@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1",
"@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -15,7 +15,25 @@ export default defineConfig({
timeout: 180_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{
name: "chromium",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Firefox"] },
},
{
name: "webkit",
testIgnore: /responsive\.spec\.ts/,
use: { ...devices["Desktop Safari"] },
},
{
name: "mobile-chromium",
testMatch: /responsive\.spec\.ts/,
use: { ...devices["Pixel 5"] },
},
],
});
+7
View File
@@ -1,5 +1,12 @@
# Changelog
## 0.2.0 - 2026-09-02
- Added vCard 2.1, grouped-property, quoted-printable and Base64 support while
preserving unmapped properties and parameters.
- Added loss-preserving jCard and focused JSContact import/export with explicit
mapping evidence.
## 0.1.0 - 2026-09-01
- Added the initial local-first Contact Tools workbench.
+3 -3
View File
@@ -1,5 +1,5 @@
==============================================================================
@add-ideas/toolbox-contract@0.2.3
@add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
==============================================================================
@add-ideas/toolbox-helpers@0.1.0
@add-ideas/toolbox-helpers@0.2.0
Declared licence: GPL-3.0-or-later
==============================================================================
--- LICENSE ---
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
==============================================================================
@add-ideas/toolbox-shell-react@0.2.3
@add-ideas/toolbox-shell-react@0.3.0
Declared licence: Apache-2.0
==============================================================================
--- LICENSE ---
+7 -4
View File
@@ -6,15 +6,18 @@ Contact Tools is a standalone local-first application in the [add·ideas Toolbox
## Version 0.1 scope
- Unfolding and bounded parsing of vCard 3.0 and 4.0 contact sets
- Unfolding and bounded parsing of vCard 2.1, 3.0 and 4.0 contact sets
- Editable core names, organizations, titles, emails, phones, addresses, URLs, birthdays, notes, UIDs and categories
- Canonical vCard 3.0/4.0 export with UTF-8-aware 75-octet line folding
- Canonical vCard 2.1/3.0/4.0 export with UTF-8-aware 75-octet line folding
- Quoted-printable and bounded Base64 decoding, grouped-property support, and loss-preserving retention of unmapped IANA/X properties and parameters
- Loss-preserving RFC 7095 jCard import/export and a focused JSContact
import/export adapter with unmapped-field evidence
- RFC 4180-style CSV import with inferred, reviewable column mapping and spreadsheet-formula-safe CSV export
- Explainable exact duplicate candidates and a conflict-reporting, left-biased merge preview
- Explicit, local vCard QR generation with a conservative payload bound and SVG download
- Masked overview fields, one-click memory clearing, and no persistence or telemetry
Text input is limited to 2 MiB, 50,000 physical lines and 2,000 contacts. Binary and quoted-printable properties are diagnosed but not decoded, and unsupported properties are reported rather than silently round-tripped. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
Text input is limited to 2 MiB, 50,000 physical lines and 2,000 contacts. Encoded values are limited to 64 KiB per property; malformed encodings are diagnosed while their raw values remain preserved. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) and [docs/PRIVACY-SECURITY.md](docs/PRIVACY-SECURITY.md).
## Development
@@ -28,7 +31,7 @@ npm run test:browser
## Release
`npm run release:artifact` creates a deterministic `release/contact-tools-0.1.0.zip` and checksum sidecar.
`npm run release:artifact` creates a deterministic `release/contact-tools-0.2.0.zip` and checksum sidecar.
## Licence
+2 -2
View File
@@ -1,7 +1,7 @@
# Corresponding source
The corresponding source for Contact Tools 0.1.0 is available at:
The corresponding source for Contact Tools 0.2.0 is available at:
https://git.add-ideas.de/lotobo/contact-tools/src/tag/v0.1.0
https://git.add-ideas.de/lotobo/contact-tools/src/tag/v0.2.0
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
+4 -4
View File
@@ -1,12 +1,12 @@
# Third-party notices
Contact Tools 0.1.0 directly depends on these runtime packages:
Contact Tools 0.2.0 directly depends on these runtime packages:
| Package | Pinned version | Declared licence |
| -------------------------------- | -------------: | ---------------- |
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
| `qrcode-generator` | 2.0.4 | MIT |
| `react` | 19.2.8 | MIT |
| `react-dom` | 19.2.8 | MIT |
+1 -1
View File
@@ -2,7 +2,7 @@
Contact Tools is a static React/Vite application wrapped in the shared Toolbox shell. `contact/model.ts` owns bounded vCard unfolding/parsing, canonical serialization, RFC 4180-style CSV conversion, exact duplicate indexing and deterministic merge previews. The workbench only replaces its active records after a complete successful parse.
Imported properties are projected into a typed contact model and rendered through React text nodes and native controls. Unknown properties are diagnosed and omitted from canonical output instead of being injected back into markup. QR SVG is generated from UTF-8 bytes of the canonical vCard by the pinned `qrcode-generator` runtime, checked for active SVG constructs, then displayed as an encoded image URL rather than inserted as markup.
Imported properties are projected into a typed contact model and rendered through React text nodes and native controls. Quoted-printable and bounded Base64 values are decoded locally. Unmapped properties retain their group, name, parameters, raw value and optional decoded bytes, are diagnosed as non-editable, and are serialized back as inert vCard content. QR SVG is generated from UTF-8 bytes of the canonical vCard by the pinned `qrcode-generator` runtime, checked for active SVG constructs, then displayed as an encoded image URL rather than inserted as markup.
Version 0.1 operations are synchronous and bounded, so the app creates no worker and has no persistence or server API. Relative entry and asset URLs keep the build relocatable below a nested portal path.
+1 -1
View File
@@ -1,5 +1,5 @@
const CACHE_PREFIX = "contact-tools-shell-";
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
self.addEventListener("install", (event) => {
event.waitUntil(
+14 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.contact-tools",
"name": "Contact Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect, convert, and reconcile contacts locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -21,6 +21,19 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{ "mediaType": "text/vcard", "extensions": [".vcf", ".vcard"] },
{ "mediaType": "application/vcard+json", "extensions": [".json"] },
{ "mediaType": "application/jscontact+json", "extensions": [".json"] }
],
"produces": [
{ "mediaType": "text/vcard", "extensions": [".vcf"] },
{ "mediaType": "application/vcard+json", "extensions": [".json"] },
{ "mediaType": "application/jscontact+json", "extensions": [".json"] }
]
},
"capabilities": { "required": [], "optional": [] },
"privacy": {
"processing": "local",
"fileUploads": true,
+230 -8
View File
@@ -1,5 +1,14 @@
import { useMemo, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
stableStringify,
triggerBlobDownload,
} from "@add-ideas/toolbox-helpers";
import {
contactsFromJCard,
contactsFromJSContact,
contactsToJCard,
contactsToJSContact,
} from "../contact/interchange";
import {
CONTACT_FIELDS,
contactsFromCsv,
@@ -20,7 +29,7 @@ const sample = `BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;
const sampleCsv = `Full Name,First Name,Last Name,Company,Email,Phone,City,Country\r\nGrace Hopper,Grace,Hopper,US Navy,grace@example.test,+1 555 0100,Arlington,United States\r\n`;
type Tab = "contacts" | "csv" | "duplicates" | "qr";
type Tab = "contacts" | "csv" | "json" | "duplicates" | "qr";
const FIELD_LABELS: Record<ContactField, string> = {
formattedName: "Full name",
givenName: "Given name",
@@ -64,7 +73,17 @@ function ContactEditor({
}) {
const set = (field: keyof Contact, value: string) =>
onChange({ ...contact, [field]: value });
const setPrimary = (kind: "emails" | "phones" | "urls", value: string) => {
const setPrimary = (
kind:
| "emails"
| "phones"
| "urls"
| "languages"
| "timezones"
| "geos"
| "instantMessaging",
value: string,
) => {
const current = contact[kind];
const next = current.length
? current.map((item, index) => (index ? item : { ...item, value }))
@@ -110,6 +129,42 @@ function ContactEditor({
onChange={(event) => set("title", event.target.value)}
/>
</label>
<label className="field">
<span>Role</span>
<input
value={contact.role}
onChange={(event) => set("role", event.target.value)}
/>
</label>
<label className="field">
<span>Kind</span>
<select
value={contact.kind}
onChange={(event) => set("kind", event.target.value)}
>
<option value="individual">Individual</option>
<option value="group">Group</option>
<option value="org">Organization</option>
<option value="location">Location</option>
<option value="application">Application</option>
<option value="device">Device</option>
</select>
</label>
<label className="field">
<span>Nicknames (comma-separated)</span>
<input
value={contact.nicknames.join(", ")}
onChange={(event) =>
onChange({
...contact,
nicknames: event.target.value
.split(",")
.map((item) => item.trim())
.filter(Boolean),
})
}
/>
</label>
<label className="field">
<span>Birthday</span>
<input
@@ -118,6 +173,21 @@ function ContactEditor({
placeholder="YYYY-MM-DD or vCard value"
/>
</label>
<label className="field">
<span>Anniversary</span>
<input
value={contact.anniversary}
onChange={(event) => set("anniversary", event.target.value)}
placeholder="YYYY-MM-DD or vCard value"
/>
</label>
<label className="field">
<span>Gender value</span>
<input
value={contact.gender}
onChange={(event) => set("gender", event.target.value)}
/>
</label>
<label className="field">
<span>Primary email</span>
<input
@@ -140,6 +210,40 @@ function ContactEditor({
onChange={(event) => setPrimary("urls", event.target.value)}
/>
</label>
<label className="field">
<span>Preferred language</span>
<input
value={contact.languages[0]?.value ?? ""}
onChange={(event) => setPrimary("languages", event.target.value)}
placeholder="de, en-GB, …"
/>
</label>
<label className="field">
<span>Timezone</span>
<input
value={contact.timezones[0]?.value ?? ""}
onChange={(event) => setPrimary("timezones", event.target.value)}
placeholder="Europe/Berlin"
/>
</label>
<label className="field">
<span>Geolocation URI</span>
<input
value={contact.geos[0]?.value ?? ""}
onChange={(event) => setPrimary("geos", event.target.value)}
placeholder="geo:52.5,13.4"
/>
</label>
<label className="field">
<span>Messaging / social URI</span>
<input
value={contact.instantMessaging[0]?.value ?? ""}
onChange={(event) =>
setPrimary("instantMessaging", event.target.value)
}
placeholder="xmpp:user@example.test"
/>
</label>
<label className="field wide">
<span>Categories (comma-separated)</span>
<input
@@ -155,6 +259,13 @@ function ContactEditor({
}
/>
</label>
{contact.media.length ? (
<p className="muted wide">
Preserved media: {contact.media.map((item) => item.kind).join(", ")}.
Binary and URI values remain unchanged in canonical vCard/jCard
output.
</p>
) : null}
<label className="field wide">
<span>Note</span>
<textarea
@@ -180,6 +291,11 @@ export function Workbench() {
const [headers, setHeaders] = useState<string[]>([]);
const [mapping, setMapping] = useState<CsvMapping>({});
const [qr, setQr] = useState("");
const [jsonKind, setJsonKind] = useState<"jcard" | "jscontact">("jcard");
const [jsonSource, setJsonSource] = useState(() =>
stableStringify(contactsToJCard(initial.contacts), 2),
);
const [jsonWarnings, setJsonWarnings] = useState<string[]>([]);
const duplicateReport = useMemo(
() => analyzeDuplicates(contacts),
[contacts],
@@ -261,6 +377,8 @@ export function Workbench() {
setHeaders([]);
setMapping({});
setQr("");
setJsonSource("");
setJsonWarnings([]);
setRevealed(false);
setError("");
};
@@ -268,6 +386,44 @@ export function Workbench() {
setContacts((values) =>
values.map((contact, index) => (index === selected ? next : contact)),
);
const exportJson = () => {
try {
if (jsonKind === "jcard") {
setJsonSource(stableStringify(contactsToJCard(contacts), 2));
setJsonWarnings([]);
} else {
const result = contactsToJSContact(contacts);
setJsonSource(stableStringify(result.data, 2));
setJsonWarnings(result.warnings);
}
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "JSON export failed.",
);
}
};
const importJson = () => {
try {
const parsed: unknown = JSON.parse(jsonSource);
const next =
jsonKind === "jcard"
? contactsFromJCard(parsed)
: contactsFromJSContact(parsed);
setContacts(next);
setSource(serializeVCards(next));
setWarnings([]);
setSelected(0);
setQr("");
setJsonWarnings([]);
setTab("contacts");
setError("");
} catch (reason) {
setError(
reason instanceof Error ? reason.message : "JSON import failed.",
);
}
};
return (
<main className="workbench">
@@ -310,19 +466,20 @@ export function Workbench() {
)}
<nav className="workspace-tabs" aria-label="Contact workspaces">
{(["contacts", "csv", "duplicates", "qr"] as const).map((value) => (
{(["contacts", "csv", "json", "duplicates", "qr"] as const).map(
(value) => (
<button
key={value}
type="button"
role="tab"
aria-selected={tab === value}
aria-pressed={tab === value}
onClick={() => setTab(value)}
>
{value === "qr"
? "Contact QR"
: value[0]!.toUpperCase() + value.slice(1)}
</button>
))}
),
)}
</nav>
{tab === "contacts" && (
@@ -334,7 +491,7 @@ export function Workbench() {
<div className="panel-heading">
<div>
<p className="eyebrow">Input</p>
<h2 id="vcard-source-heading">vCard 3.0 / 4.0</h2>
<h2 id="vcard-source-heading">vCard 2.1 / 3.0 / 4.0</h2>
</div>
<label className="button file-button">
Open .vcf
@@ -365,6 +522,7 @@ export function Workbench() {
>
<option value="4.0">Canonical vCard 4.0</option>
<option value="3.0">Canonical vCard 3.0</option>
<option value="2.1">Compatible vCard 2.1</option>
</select>
<button
type="button"
@@ -521,6 +679,70 @@ export function Workbench() {
</div>
)}
{tab === "json" && (
<section className="panel workspace">
<div className="panel-heading">
<div>
<p className="eyebrow">RFC 7095 · RFC 9553</p>
<h2>JSON contact interchange</h2>
</div>
</div>
<div className="actions">
<select
aria-label="JSON contact format"
className="compact"
value={jsonKind}
onChange={(event) =>
setJsonKind(event.target.value as "jcard" | "jscontact")
}
>
<option value="jcard">jCard (loss-preserving vCard model)</option>
<option value="jscontact">JSContact 1.0 (semantic model)</option>
</select>
<button type="button" onClick={exportJson}>
Export current contacts
</button>
<button type="button" className="primary" onClick={importJson}>
Import JSON contacts
</button>
<button
type="button"
disabled={!jsonSource}
onClick={() =>
downloadText(
jsonSource,
jsonKind === "jcard"
? "contacts.jcard.json"
: "contacts.jscontact.json",
jsonKind === "jcard"
? "application/vcard+json"
: "application/jscontact+json",
)
}
>
Download JSON
</button>
</div>
<textarea
aria-label="JSON contact source"
value={jsonSource}
onChange={(event) => setJsonSource(event.target.value)}
spellCheck={false}
/>
<p className="muted">
jCard mirrors vCard properties and is the safer loss-preserving
handoff. JSContact is a richer semantic model; this bounded adapter
maps names, organizations, titles, contact methods, addresses,
languages, links, anniversaries, keywords and notes.
</p>
{jsonWarnings.map((warning) => (
<p className="warning" key={warning}>
{warning}
</p>
))}
</section>
)}
{tab === "duplicates" && (
<section className="panel workspace">
<div className="panel-heading">
+495
View File
@@ -0,0 +1,495 @@
import type {
AddressValue,
Contact,
ContactValue,
PreservedVCardProperty,
VCardParameter,
} from "./model";
import { parseVCards } from "./model";
type JsonObject = Record<string, unknown>;
type JCardProperty = [string, JsonObject, string, ...unknown[]];
type JCard = ["vcard", JCardProperty[]];
const MAX_JSON = 2 * 1024 * 1024;
const MAX_CARDS = 2_000;
const MAX_PROPERTIES = 50_000;
function object(value: unknown, label: string): JsonObject {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new TypeError(`${label} must be an object.`);
return value as JsonObject;
}
function string(value: unknown): string {
return typeof value === "string" ? value.slice(0, 65_536) : "";
}
function idMap(value: unknown): JsonObject {
return value && typeof value === "object" && !Array.isArray(value)
? (value as JsonObject)
: (Object.create(null) as JsonObject);
}
function contexts(types: readonly string[]): JsonObject | undefined {
const entries = types
.filter((type) => /^(?:private|work|billing|delivery)$/u.test(type))
.map((type) => [type === "private" ? "private" : type, true]);
return entries.length ? Object.fromEntries(entries) : undefined;
}
function jCardParameters(item: {
group?: string;
parameters?: readonly VCardParameter[];
types?: readonly string[];
preferred?: boolean;
}): JsonObject {
const result: JsonObject = Object.create(null) as JsonObject;
if (item.group) result.group = item.group.toLowerCase();
if (item.types?.length)
result.type = item.types.length === 1 ? item.types[0] : [...item.types];
if (item.preferred) result.pref = "1";
for (const parameter of item.parameters ?? []) {
const name = parameter.name.toLowerCase();
if (["type", "pref", "encoding", "charset", "value"].includes(name))
continue;
const values = parameter.value.split(",");
result[name] = values.length === 1 ? values[0] : values;
}
return result;
}
function simpleProperty(
name: string,
value: string,
type = "text",
parameters: JsonObject = {},
): JCardProperty {
return [name.toLowerCase(), parameters, type, value];
}
function contactValueProperties(
name: string,
values: readonly ContactValue[],
type: string,
): JCardProperty[] {
return values.map((item) => [
name.toLowerCase(),
jCardParameters(item),
type,
item.value,
]);
}
function addressProperty(item: AddressValue): JCardProperty {
return [
"adr",
jCardParameters(item),
"text",
[
item.poBox,
item.extended,
item.street,
item.locality,
item.region,
item.postalCode,
item.country,
],
];
}
function preservedProperty(item: PreservedVCardProperty): JCardProperty {
return [
item.name.toLowerCase(),
jCardParameters(item),
"unknown",
item.decodedValue ?? item.rawValue,
];
}
export function contactToJCard(contact: Contact): JCard {
const properties: JCardProperty[] = [
simpleProperty("version", "4.0"),
simpleProperty("fn", contact.formattedName),
[
"n",
{},
"text",
[
contact.familyName,
contact.givenName,
contact.additionalNames,
contact.honorificPrefix,
contact.honorificSuffix,
],
],
];
const add = (name: string, value: string, type = "text") => {
if (value) properties.push(simpleProperty(name, value, type));
};
add("kind", contact.kind);
if (contact.nicknames.length)
properties.push(["nickname", {}, "text", ...contact.nicknames]);
add("org", contact.organization);
add("title", contact.title);
add("role", contact.role);
add("bday", contact.birthday, "date-and-or-time");
add("anniversary", contact.anniversary, "date-and-or-time");
add("gender", contact.gender);
add("note", contact.note);
add("uid", contact.uid, "uri");
if (contact.categories.length)
properties.push(["categories", {}, "text", ...contact.categories]);
properties.push(
...contactValueProperties("email", contact.emails, "text"),
...contactValueProperties("tel", contact.phones, "uri"),
...contactValueProperties("url", contact.urls, "uri"),
...contact.addresses.map(addressProperty),
...contactValueProperties("lang", contact.languages, "language-tag"),
...contactValueProperties("tz", contact.timezones, "text"),
...contactValueProperties("geo", contact.geos, "uri"),
...contactValueProperties("impp", contact.instantMessaging, "uri"),
...contactValueProperties("related", contact.related, "uri"),
...contactValueProperties("member", contact.members, "uri"),
...contact.preservedProperties.map(preservedProperty),
);
return ["vcard", properties];
}
export function contactsToJCard(contacts: readonly Contact[]): JCard[] {
if (contacts.length > MAX_CARDS)
throw new RangeError("jCard export is limited to 2,000 contacts.");
return contacts.map(contactToJCard);
}
function vcardEscape(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll("\r\n", "\n")
.replaceAll("\r", "\n")
.replaceAll("\n", "\\n")
.replaceAll(";", "\\;")
.replaceAll(",", "\\,");
}
function jCardValue(value: unknown): string {
if (Array.isArray(value))
return value
.map((item) =>
Array.isArray(item)
? item.map((part) => vcardEscape(string(part))).join(",")
: vcardEscape(string(item)),
)
.join(";");
if (["string", "number", "boolean"].includes(typeof value))
return vcardEscape(String(value));
throw new TypeError(
"jCard property values must be scalar or structured arrays.",
);
}
function propertyToVCard(property: unknown): string {
if (!Array.isArray(property) || property.length < 4)
throw new TypeError(
"Every jCard property needs name, parameters, type and value.",
);
const name = string(property[0]).toUpperCase();
if (!/^[A-Z0-9-]+$/u.test(name))
throw new TypeError(`Invalid jCard property name ${name || "(empty)"}.`);
const params = object(property[1], `${name} parameters`);
const group = string(params.group).toUpperCase();
if (group && !/^[A-Z0-9-]+$/u.test(group))
throw new TypeError(`Invalid jCard group ${group}.`);
const serializedParameters = Object.entries(params)
.filter(([key]) => key.toLowerCase() !== "group")
.map(([key, value]) => {
if (!/^[A-Z0-9-]+$/iu.test(key))
throw new TypeError(`Invalid jCard parameter ${key}.`);
const values = Array.isArray(value) ? value : [value];
return `;${key.toUpperCase()}=${values
.map(string)
.join(",")
.replace(/[\r\n]/gu, "")}`;
})
.join("");
const values = property.slice(3);
const value =
values.length === 1 && Array.isArray(values[0])
? jCardValue(values[0])
: values.map(jCardValue).join(",");
return `${group ? `${group}.` : ""}${name}${serializedParameters}:${value}`;
}
export function contactsFromJCard(input: unknown): Contact[] {
const serialized = JSON.stringify(input);
if (serialized.length > MAX_JSON)
throw new RangeError("jCard input exceeds the 2 MiB JSON limit.");
const cards = Array.isArray(input) && input[0] === "vcard" ? [input] : input;
if (!Array.isArray(cards) || !cards.length || cards.length > MAX_CARDS)
throw new RangeError("jCard input must contain 12,000 vcard objects.");
let propertyCount = 0;
const vcard = cards
.map((candidate, index) => {
if (
!Array.isArray(candidate) ||
candidate[0] !== "vcard" ||
!Array.isArray(candidate[1])
)
throw new TypeError(`jCard item ${index + 1} is not a vcard object.`);
propertyCount += candidate[1].length;
if (propertyCount > MAX_PROPERTIES)
throw new RangeError("jCard input exceeds 50,000 properties.");
const lines = candidate[1].map(propertyToVCard);
if (!/^VERSION:/iu.test(lines[0] ?? ""))
throw new TypeError(`jCard item ${index + 1} must start with version.`);
return `BEGIN:VCARD\r\n${lines.join("\r\n")}\r\nEND:VCARD`;
})
.join("\r\n");
return parseVCards(vcard).contacts;
}
function jsContexts(item: ContactValue): JsonObject | undefined {
return contexts(item.types);
}
function keyed<T>(
values: readonly T[],
prefix: string,
convert: (value: T) => unknown,
) {
return Object.fromEntries(
values.map((value, index) => [`${prefix}${index + 1}`, convert(value)]),
);
}
export interface JSContactConversion<T> {
data: T;
warnings: string[];
}
export function contactsToJSContact(
contacts: readonly Contact[],
): JSContactConversion<JsonObject[]> {
const warnings: string[] = [];
const data = contacts.map((contact, cardIndex) => {
if (contact.preservedProperties.length)
warnings.push(
`Contact ${cardIndex + 1}: ${contact.preservedProperties.length} preserved vCard properties require jCard for lossless interchange.`,
);
const components = [
["title", contact.honorificPrefix],
["given", contact.givenName],
["given2", contact.additionalNames],
["surname", contact.familyName],
["credential", contact.honorificSuffix],
]
.filter((entry) => entry[1])
.map(([kind, value]) => ({ kind, value }));
return {
"@type": "Card",
version: "1.0",
uid: contact.uid || `local-contact-${cardIndex + 1}`,
kind: contact.kind || "individual",
name: { full: contact.formattedName, components },
nicknames: keyed(contact.nicknames, "nickname", (name) => ({ name })),
organizations: contact.organization
? { organization1: { name: contact.organization } }
: undefined,
titles:
contact.title || contact.role
? {
...(contact.title
? { title1: { name: contact.title, kind: "title" } }
: {}),
...(contact.role
? { role1: { name: contact.role, kind: "role" } }
: {}),
}
: undefined,
emails: keyed(contact.emails, "email", (item) => ({
address: item.value,
contexts: jsContexts(item),
pref: item.preferred ? 1 : undefined,
})),
phones: keyed(contact.phones, "phone", (item) => ({
number: item.value,
contexts: jsContexts(item),
features: Object.fromEntries(
item.types
.filter((type) => !["home", "work", "private"].includes(type))
.map((type) => [type, true]),
),
pref: item.preferred ? 1 : undefined,
})),
onlineServices: keyed(contact.instantMessaging, "service", (item) => ({
uri: item.value,
contexts: jsContexts(item),
pref: item.preferred ? 1 : undefined,
})),
addresses: keyed(contact.addresses, "address", (item) => ({
full: item.value || undefined,
contexts: jsContexts(item),
pref: item.preferred ? 1 : undefined,
components: [
["postOfficeBox", item.poBox],
["apartment", item.extended],
["street", item.street],
["locality", item.locality],
["region", item.region],
["postcode", item.postalCode],
["country", item.country],
]
.filter((entry) => entry[1])
.map(([kind, value]) => ({ kind, value })),
})),
preferredLanguages: keyed(contact.languages, "language", (item) => ({
language: item.value,
contexts: jsContexts(item),
pref: item.preferred ? 1 : undefined,
})),
links: keyed(contact.urls, "link", (item) => ({
"@type": "Link",
uri: item.value,
contexts: jsContexts(item),
pref: item.preferred ? 1 : undefined,
})),
anniversaries: {
...(contact.birthday
? { birth: { kind: "birth", date: contact.birthday } }
: {}),
...(contact.anniversary
? { wedding: { kind: "wedding", date: contact.anniversary } }
: {}),
},
keywords: Object.fromEntries(
contact.categories.map((item) => [item, true]),
),
notes: contact.note ? { note1: { note: contact.note } } : undefined,
};
});
return { data, warnings };
}
function mapObjects(value: unknown): JsonObject[] {
return Object.values(idMap(value))
.filter((item) => item && typeof item === "object" && !Array.isArray(item))
.map((item) => item as JsonObject);
}
function typesFromContexts(value: unknown): string[] {
return Object.entries(idMap(value))
.filter(([, enabled]) => enabled === true)
.map(([name]) => name);
}
export function contactsFromJSContact(input: unknown): Contact[] {
if (JSON.stringify(input).length > MAX_JSON)
throw new RangeError("JSContact input exceeds the 2 MiB JSON limit.");
const cards = Array.isArray(input) ? input : [input];
if (!cards.length || cards.length > MAX_CARDS)
throw new RangeError("JSContact input must contain 12,000 cards.");
const vcard = cards
.map((value, index) => {
const card = object(value, `JSContact card ${index + 1}`);
if (card["@type"] !== "Card" || card.version !== "1.0")
throw new TypeError(
"JSContact cards require @type Card and version 1.0.",
);
const name = object(card.name, `JSContact card ${index + 1} name`);
const components = Array.isArray(name.components) ? name.components : [];
const named = (kind: string) =>
components
.filter(
(item) =>
item &&
typeof item === "object" &&
!Array.isArray(item) &&
(item as JsonObject).kind === kind,
)
.map((item) => string((item as JsonObject).value))
.filter(Boolean)
.join(" ");
const lines = [
"BEGIN:VCARD",
"VERSION:4.0",
`FN:${vcardEscape(string(name.full) || [named("given"), named("surname")].filter(Boolean).join(" ") || `Contact ${index + 1}`)}`,
`N:${[named("surname"), named("given"), named("given2"), named("title"), named("credential")].map(vcardEscape).join(";")}`,
];
const add = (name: string, value: string) => {
if (value) lines.push(`${name}:${vcardEscape(value)}`);
};
add("UID", string(card.uid));
add("KIND", string(card.kind));
const nicknames = mapObjects(card.nicknames).map((item) =>
string(item.name),
);
if (nicknames.length)
lines.push(`NICKNAME:${nicknames.map(vcardEscape).join(",")}`);
const organization = mapObjects(card.organizations)[0];
add("ORG", string(organization?.name));
for (const title of mapObjects(card.titles))
add(title.kind === "role" ? "ROLE" : "TITLE", string(title.name));
for (const item of mapObjects(card.emails))
lines.push(
`EMAIL${typesFromContexts(item.contexts)
.map((type) => `;TYPE=${type}`)
.join(
"",
)}${Number(item.pref) === 1 ? ";PREF=1" : ""}:${vcardEscape(string(item.address))}`,
);
for (const item of mapObjects(card.phones))
lines.push(
`TEL${typesFromContexts(item.contexts)
.map((type) => `;TYPE=${type}`)
.join("")}:${vcardEscape(string(item.number))}`,
);
for (const item of mapObjects(card.links)) add("URL", string(item.uri));
for (const item of mapObjects(card.onlineServices))
add("IMPP", string(item.uri));
for (const item of mapObjects(card.preferredLanguages))
add("LANG", string(item.language));
for (const address of mapObjects(card.addresses)) {
const parts: Record<string, string[]> = Object.create(null) as Record<
string,
string[]
>;
for (const part of Array.isArray(address.components)
? address.components
: []) {
if (!part || typeof part !== "object" || Array.isArray(part))
continue;
const item = part as JsonObject;
const kind = string(item.kind);
if (kind) (parts[kind] ??= []).push(string(item.value));
}
lines.push(
`ADR:${[
parts.postOfficeBox?.join(" ") ?? "",
parts.apartment?.join(" ") ?? "",
parts.street?.join(" ") ?? "",
parts.locality?.join(" ") ?? "",
parts.region?.join(" ") ?? "",
parts.postcode?.join(" ") ?? "",
parts.country?.join(" ") ?? "",
]
.map(vcardEscape)
.join(";")}`,
);
}
for (const anniversary of mapObjects(card.anniversaries))
add(
anniversary.kind === "birth" ? "BDAY" : "ANNIVERSARY",
string(anniversary.date),
);
const keywords = Object.entries(idMap(card.keywords))
.filter(([, enabled]) => enabled === true)
.map(([name]) => name);
if (keywords.length)
lines.push(`CATEGORIES:${keywords.map(vcardEscape).join(",")}`);
for (const note of mapObjects(card.notes)) add("NOTE", string(note.note));
lines.push("END:VCARD");
return lines.join("\r\n");
})
.join("\r\n");
return parseVCards(vcard).contacts;
}
+342 -30
View File
@@ -1,9 +1,28 @@
export type VCardVersion = "3.0" | "4.0";
import { base64ToBytes } from "@add-ideas/toolbox-helpers";
export type VCardVersion = "2.1" | "3.0" | "4.0";
export interface VCardParameter {
name: string;
value: string;
}
export interface PreservedVCardProperty {
group?: string;
name: string;
parameters: VCardParameter[];
rawValue: string;
decodedValue?: string;
decodedBytes?: Uint8Array<ArrayBuffer>;
encoding?: string;
}
export interface ContactValue {
value: string;
types: string[];
preferred: boolean;
group?: string;
parameters?: VCardParameter[];
}
export interface AddressValue extends ContactValue {
@@ -16,6 +35,14 @@ export interface AddressValue extends ContactValue {
country: string;
}
export interface ContactMedia {
kind: "PHOTO" | "LOGO" | "KEY";
value: string;
group?: string;
parameters: VCardParameter[];
decodedBytes?: Uint8Array<ArrayBuffer>;
}
export interface Contact {
id: string;
version: VCardVersion;
@@ -27,14 +54,27 @@ export interface Contact {
honorificSuffix: string;
organization: string;
title: string;
kind: string;
nicknames: string[];
role: string;
anniversary: string;
gender: string;
emails: ContactValue[];
phones: ContactValue[];
addresses: AddressValue[];
urls: ContactValue[];
languages: ContactValue[];
timezones: ContactValue[];
geos: ContactValue[];
instantMessaging: ContactValue[];
related: ContactValue[];
members: ContactValue[];
media: ContactMedia[];
birthday: string;
note: string;
uid: string;
categories: string[];
preservedProperties: PreservedVCardProperty[];
}
export interface ParseWarning {
@@ -87,14 +127,27 @@ function emptyContact(index: number): Contact {
honorificSuffix: "",
organization: "",
title: "",
kind: "individual",
nicknames: [],
role: "",
anniversary: "",
gender: "",
emails: [],
phones: [],
addresses: [],
urls: [],
languages: [],
timezones: [],
geos: [],
instantMessaging: [],
related: [],
members: [],
media: [],
birthday: "",
note: "",
uid: "",
categories: [],
preservedProperties: [],
};
}
@@ -160,7 +213,16 @@ function unfold(source: string): { text: string; line: number }[] {
throw new Error("Contact input exceeds the 50,000-line limit.");
const logical: { text: string; line: number }[] = [];
for (const [index, text] of physical.entries()) {
if (/^[ \t]/u.test(text) && logical.length)
const quotedPrintableContinuation =
logical.length > 0 &&
/(?:^|;)ENCODING=QUOTED-PRINTABLE(?:;|:)/iu.test(
logical[logical.length - 1]!.text,
) &&
logical[logical.length - 1]!.text.endsWith("=");
if (quotedPrintableContinuation) {
logical[logical.length - 1]!.text =
logical[logical.length - 1]!.text.slice(0, -1) + text;
} else if (/^[ \t]/u.test(text) && logical.length)
logical[logical.length - 1]!.text += text.slice(1);
else logical.push({ text, line: index + 1 });
}
@@ -168,11 +230,41 @@ function unfold(source: string): { text: string; line: number }[] {
}
interface ParsedProperty {
group?: string;
name: string;
rawValue: string;
value: string;
types: string[];
preferred: boolean;
unsupportedEncoding: boolean;
parameters: VCardParameter[];
encoding?: string;
decodedBytes?: Uint8Array<ArrayBuffer>;
decodingError?: string;
}
function quotedPrintableBytes(input: string): Uint8Array<ArrayBuffer> {
const bytes: number[] = [];
for (let index = 0; index < input.length; index += 1) {
if (
input[index] === "=" &&
/^[0-9a-f]{2}$/iu.test(input.slice(index + 1, index + 3))
) {
bytes.push(Number.parseInt(input.slice(index + 1, index + 3), 16));
index += 2;
} else bytes.push(input.charCodeAt(index) & 0xff);
}
return new Uint8Array(bytes);
}
function decodePropertyBytes(
bytes: Uint8Array<ArrayBuffer>,
charset: string,
): string {
try {
return new TextDecoder(charset || "utf-8", { fatal: false }).decode(bytes);
} catch {
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
}
}
function parseProperty(line: string): ParsedProperty | null {
@@ -184,6 +276,9 @@ function parseProperty(line: string): ParsedProperty | null {
throw new Error("A vCard property exceeds 64 KiB.");
const tokens = splitEscaped(left, ";");
const rawName = tokens.shift() ?? "";
const group = rawName.includes(".")
? rawName.slice(0, rawName.lastIndexOf("."))
: undefined;
const name = (
rawName.includes(".")
? rawName.slice(rawName.lastIndexOf(".") + 1)
@@ -191,27 +286,54 @@ function parseProperty(line: string): ParsedProperty | null {
).toUpperCase();
const types = new Set<string>();
let preferred = false;
let unsupportedEncoding = false;
const parameters: VCardParameter[] = [];
let encoding: string | undefined;
let charset = "utf-8";
for (const token of tokens) {
const equals = token.indexOf("=");
const key = (equals < 0 ? "TYPE" : token.slice(0, equals)).toUpperCase();
const value =
equals < 0 ? token : token.slice(equals + 1).replace(/^"|"$/gu, "");
parameters.push({ name: key, value });
if (key === "TYPE")
for (const type of value.split(","))
if (type.trim()) types.add(type.trim().toLowerCase());
if (key === "PREF" && value !== "0" && value.toLowerCase() !== "false")
preferred = true;
if (key === "ENCODING" && /^(?:b|base64|quoted-printable)$/iu.test(value))
unsupportedEncoding = true;
if (key === "ENCODING") encoding = value.toLowerCase();
if (key === "CHARSET") charset = value;
}
if (types.has("pref")) preferred = true;
let decodedRawValue = rawValue;
let decodedBytes: Uint8Array<ArrayBuffer> | undefined;
let decodingError: string | undefined;
if (encoding && /^(?:b|base64|quoted-printable)$/iu.test(encoding)) {
try {
decodedBytes =
encoding === "quoted-printable"
? quotedPrintableBytes(rawValue)
: base64ToBytes(
rawValue.replace(/\s/gu, "") +
"=".repeat((4 - (rawValue.replace(/\s/gu, "").length % 4)) % 4),
{ maxOutputBytes: MAX_VALUE },
);
decodedRawValue = decodePropertyBytes(decodedBytes, charset);
} catch (reason) {
decodingError =
reason instanceof Error ? reason.message : "encoded value is invalid";
}
}
return {
...(group ? { group } : {}),
name,
value: unescapeValue(rawValue),
rawValue: decodedRawValue,
value: unescapeValue(decodedRawValue),
types: [...types],
preferred,
unsupportedEncoding,
parameters,
...(encoding ? { encoding } : {}),
...(decodedBytes ? { decodedBytes } : {}),
...(decodingError ? { decodingError } : {}),
};
}
@@ -221,6 +343,25 @@ function parseCard(
warnings: ParseWarning[],
): Contact {
const contact = emptyContact(cardIndex);
const contactValue = (property: ParsedProperty): ContactValue => ({
value: property.value,
types: property.types,
preferred: property.preferred,
...(property.group ? { group: property.group } : {}),
parameters: property.parameters,
});
const preserve = (
property: ParsedProperty,
rawLine: string,
): PreservedVCardProperty => ({
...(property.group ? { group: property.group } : {}),
name: property.name,
parameters: property.parameters,
rawValue: rawLine.slice(findValueColon(rawLine) + 1),
...(property.decodingError ? {} : { decodedValue: property.value }),
...(property.decodedBytes ? { decodedBytes: property.decodedBytes } : {}),
...(property.encoding ? { encoding: property.encoding } : {}),
});
let sawName = false;
for (const entry of lines) {
const property = parseProperty(entry.text);
@@ -233,18 +374,18 @@ function parseCard(
});
continue;
}
if (property.unsupportedEncoding) {
if (property.decodingError) {
warnings.push({
card: cardIndex,
line: entry.line,
message: `${property.name} uses an encoded or binary value that v0.1 does not decode.`,
message: `${property.name} could not be decoded (${property.decodingError}); its original property is preserved.`,
});
continue;
}
const value = property.value;
switch (property.name) {
case "VERSION":
if (value === "3.0" || value === "4.0") contact.version = value;
if (value === "2.1" || value === "3.0" || value === "4.0")
contact.version = value;
else
warnings.push({
card: cardIndex,
@@ -256,10 +397,7 @@ function parseCard(
contact.formattedName ||= value;
break;
case "N": {
const parts = splitEscaped(
entry.text.slice(findValueColon(entry.text) + 1),
";",
).map(unescapeValue);
const parts = splitEscaped(property.rawValue, ";").map(unescapeValue);
[
contact.familyName,
contact.givenName,
@@ -277,21 +415,39 @@ function parseCard(
break;
}
case "ORG":
contact.organization ||= splitEscaped(
entry.text.slice(findValueColon(entry.text) + 1),
";",
)
contact.organization ||= splitEscaped(property.rawValue, ";")
.map(unescapeValue)
.join(" · ");
break;
case "TITLE":
contact.title ||= value;
break;
case "KIND":
contact.kind = value || "individual";
break;
case "NICKNAME":
contact.nicknames.push(
...splitEscaped(property.rawValue, ",")
.map(unescapeValue)
.filter(Boolean),
);
break;
case "ROLE":
contact.role ||= value;
break;
case "ANNIVERSARY":
contact.anniversary ||= value;
break;
case "GENDER":
contact.gender ||= value;
break;
case "EMAIL":
contact.emails.push({
value: value.replace(/^mailto:/iu, ""),
types: property.types,
preferred: property.preferred,
...(property.group ? { group: property.group } : {}),
parameters: property.parameters,
});
break;
case "TEL":
@@ -299,6 +455,8 @@ function parseCard(
value: value.replace(/^tel:/iu, ""),
types: property.types,
preferred: property.preferred,
...(property.group ? { group: property.group } : {}),
parameters: property.parameters,
});
break;
case "URL":
@@ -306,17 +464,50 @@ function parseCard(
value,
types: property.types,
preferred: property.preferred,
...(property.group ? { group: property.group } : {}),
parameters: property.parameters,
});
break;
case "LANG":
contact.languages.push(contactValue(property));
break;
case "TZ":
contact.timezones.push(contactValue(property));
break;
case "GEO":
contact.geos.push(contactValue(property));
break;
case "IMPP":
contact.instantMessaging.push(contactValue(property));
break;
case "RELATED":
contact.related.push(contactValue(property));
break;
case "MEMBER":
contact.members.push(contactValue(property));
break;
case "PHOTO":
case "LOGO":
case "KEY":
contact.media.push({
kind: property.name,
value,
...(property.group ? { group: property.group } : {}),
parameters: property.parameters,
...(property.decodedBytes
? { decodedBytes: property.decodedBytes }
: {}),
});
contact.preservedProperties.push(preserve(property, entry.text));
break;
case "ADR": {
const parts = splitEscaped(
entry.text.slice(findValueColon(entry.text) + 1),
";",
).map(unescapeValue);
const parts = splitEscaped(property.rawValue, ";").map(unescapeValue);
contact.addresses.push({
value,
types: property.types,
preferred: property.preferred,
...(property.group ? { group: property.group } : {}),
parameters: property.parameters,
poBox: parts[0] ?? "",
extended: parts[1] ?? "",
street: parts[2] ?? "",
@@ -338,7 +529,7 @@ function parseCard(
break;
case "CATEGORIES":
contact.categories.push(
...splitEscaped(entry.text.slice(findValueColon(entry.text) + 1), ",")
...splitEscaped(property.rawValue, ",")
.map(unescapeValue)
.filter(Boolean),
);
@@ -347,10 +538,11 @@ function parseCard(
case "END":
break;
default:
contact.preservedProperties.push(preserve(property, entry.text));
warnings.push({
card: cardIndex,
line: entry.line,
message: `Preservation of ${property.name} is not supported; it will not appear in canonical export.`,
message: `${property.name} is not mapped to an editor field; its group, parameters, and raw value are preserved for canonical export.`,
});
}
}
@@ -463,9 +655,38 @@ function parameters(item: ContactValue, version: VCardVersion): string {
const parts: string[] = [];
if (types.length) parts.push(`TYPE=${types.join(",")}`);
if (item.preferred) parts.push(version === "4.0" ? "PREF=1" : "TYPE=PREF");
for (const parameter of item.parameters ?? []) {
const name = parameter.name.toUpperCase();
if (["TYPE", "PREF", "ENCODING", "CHARSET", "VALUE"].includes(name))
continue;
if (!/^[A-Z0-9-]+$/u.test(name)) continue;
const value = parameter.value.replace(/[\r\n]/gu, "");
if (value) parts.push(`${name}=${value}`);
}
return parts.length ? `;${parts.join(";")}` : "";
}
function propertyPrefix(item: { group?: string }, name: string): string {
const group = item.group?.replace(/[^A-Za-z0-9-]/gu, "");
return `${group ? `${group}.` : ""}${name}`;
}
function preservedPropertyLine(property: PreservedVCardProperty): string {
const name = property.name.toUpperCase();
if (!/^(?:X-[A-Z0-9-]+|[A-Z][A-Z0-9-]*)$/u.test(name))
throw new Error(`Preserved property name is invalid: ${property.name}`);
const parameters = property.parameters
.filter((parameter) => /^[A-Z0-9-]+$/iu.test(parameter.name))
.map((parameter) => {
const value = parameter.value.replace(/[\r\n]/gu, "");
return value ? `;${parameter.name.toUpperCase()}=${value}` : "";
})
.join("");
if (/[\r\n]/u.test(property.rawValue))
throw new Error(`Preserved ${name} value contains a physical newline.`);
return `${propertyPrefix(property, name)}${parameters}:${property.rawValue}`;
}
export function serializeVCards(
contacts: readonly Contact[],
version: VCardVersion = "4.0",
@@ -484,21 +705,43 @@ export function serializeVCards(
if (contact.organization)
lines.push(`ORG:${escapeValue(contact.organization)}`);
if (contact.title) lines.push(`TITLE:${escapeValue(contact.title)}`);
if (contact.kind && contact.kind !== "individual")
lines.push(`KIND:${escapeValue(contact.kind)}`);
if (contact.nicknames.length)
lines.push(
`NICKNAME:${contact.nicknames.map(escapeValue).join(",")}`,
);
if (contact.role) lines.push(`ROLE:${escapeValue(contact.role)}`);
if (contact.anniversary)
lines.push(`ANNIVERSARY:${escapeValue(contact.anniversary)}`);
if (contact.gender) lines.push(`GENDER:${escapeValue(contact.gender)}`);
for (const item of contact.emails)
lines.push(
`EMAIL${parameters(item, version)}:${escapeValue(item.value)}`,
`${propertyPrefix(item, "EMAIL")}${parameters(item, version)}:${escapeValue(item.value)}`,
);
for (const item of contact.phones)
lines.push(
`TEL${parameters(item, version)}:${version === "4.0" && !/^tel:/iu.test(item.value) ? "tel:" : ""}${escapeValue(item.value)}`,
`${propertyPrefix(item, "TEL")}${parameters(item, version)}:${version === "4.0" && !/^tel:/iu.test(item.value) ? "tel:" : ""}${escapeValue(item.value)}`,
);
for (const item of contact.addresses)
lines.push(
`ADR${parameters(item, version)}:${[item.poBox, item.extended, item.street, item.locality, item.region, item.postalCode, item.country].map(escapeValue).join(";")}`,
`${propertyPrefix(item, "ADR")}${parameters(item, version)}:${[item.poBox, item.extended, item.street, item.locality, item.region, item.postalCode, item.country].map(escapeValue).join(";")}`,
);
for (const item of contact.urls)
lines.push(
`URL${parameters(item, version)}:${escapeValue(item.value)}`,
`${propertyPrefix(item, "URL")}${parameters(item, version)}:${escapeValue(item.value)}`,
);
for (const [name, values] of [
["LANG", contact.languages],
["TZ", contact.timezones],
["GEO", contact.geos],
["IMPP", contact.instantMessaging],
["RELATED", contact.related],
["MEMBER", contact.members],
] as const)
for (const item of values)
lines.push(
`${propertyPrefix(item, name)}${parameters(item, version)}:${escapeValue(item.value)}`,
);
if (contact.birthday)
lines.push(`BDAY:${escapeValue(contact.birthday)}`);
@@ -508,6 +751,8 @@ export function serializeVCards(
lines.push(
`CATEGORIES:${contact.categories.map(escapeValue).join(",")}`,
);
for (const property of contact.preservedProperties)
lines.push(preservedPropertyLine(property));
lines.push("END:VCARD");
return lines.map(foldLine);
})
@@ -854,6 +1099,22 @@ function uniqueValues(
.map((item) => ({ ...item, types: [...item.types] }));
}
function uniqueGenericValues(values: readonly ContactValue[]): ContactValue[] {
const seen = new Set<string>();
return values
.filter((item) => {
const key = `${normalizeText(item.value)}|${[...item.types].sort().join(",")}`;
if (!key || seen.has(key)) return false;
seen.add(key);
return true;
})
.map((item) => ({
...item,
types: [...item.types],
parameters: item.parameters?.map((parameter) => ({ ...parameter })),
}));
}
export function mergeContacts(
left: Contact,
right: Contact,
@@ -888,6 +1149,11 @@ export function mergeContacts(
),
organization: choose("Organization", left.organization, right.organization),
title: choose("Title", left.title, right.title),
kind: choose("Kind", left.kind, right.kind),
nicknames: [...new Set([...left.nicknames, ...right.nicknames])],
role: choose("Role", left.role, right.role),
anniversary: choose("Anniversary", left.anniversary, right.anniversary),
gender: choose("Gender", left.gender, right.gender),
birthday: choose("Birthday", left.birthday, right.birthday),
note: [left.note, right.note]
.filter(Boolean)
@@ -897,6 +1163,30 @@ export function mergeContacts(
emails: uniqueValues([...left.emails, ...right.emails], "email"),
phones: uniqueValues([...left.phones, ...right.phones], "phone"),
urls: uniqueValues([...left.urls, ...right.urls], "url"),
languages: uniqueGenericValues([...left.languages, ...right.languages]),
timezones: uniqueGenericValues([...left.timezones, ...right.timezones]),
geos: uniqueGenericValues([...left.geos, ...right.geos]),
instantMessaging: uniqueGenericValues([
...left.instantMessaging,
...right.instantMessaging,
]),
related: uniqueGenericValues([...left.related, ...right.related]),
members: uniqueGenericValues([...left.members, ...right.members]),
media: [...left.media, ...right.media]
.filter(
(item, index, values) =>
values.findIndex(
(candidate) =>
candidate.kind === item.kind && candidate.value === item.value,
) === index,
)
.map((item) => ({
...item,
parameters: item.parameters.map((parameter) => ({ ...parameter })),
...(item.decodedBytes
? { decodedBytes: item.decodedBytes.slice() }
: {}),
})),
addresses: [...left.addresses, ...right.addresses]
.filter(
(item, index, values) =>
@@ -918,6 +1208,28 @@ export function mergeContacts(
)
.map((item) => ({ ...item, types: [...item.types] })),
categories: [...new Set([...left.categories, ...right.categories])],
preservedProperties: [
...left.preservedProperties,
...right.preservedProperties,
]
.filter(
(property, index, values) =>
values.findIndex(
(candidate) =>
candidate.group === property.group &&
candidate.name === property.name &&
candidate.rawValue === property.rawValue &&
JSON.stringify(candidate.parameters) ===
JSON.stringify(property.parameters),
) === index,
)
.map((property) => ({
...property,
parameters: property.parameters.map((parameter) => ({ ...parameter })),
...(property.decodedBytes
? { decodedBytes: property.decodedBytes.slice() }
: {}),
})),
};
return { merged, conflicts };
}
+1 -1
View File
@@ -161,7 +161,7 @@ textarea {
overflow-x: auto;
padding-bottom: 0.2rem;
}
.workspace-tabs button[aria-selected="true"] {
.workspace-tabs button[aria-pressed="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
+35 -1
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1,
"id": "de.add-ideas.contact-tools",
"name": "Contact Tools",
"version": "0.1.0",
"version": "0.2.0",
"description": "Inspect, convert, and reconcile contacts locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
@@ -21,6 +21,40 @@
"crossOriginIsolated": false,
"topLevelContext": false
},
"io": {
"accepts": [
{
"mediaType": "text/vcard",
"extensions": [".vcf", ".vcard"]
},
{
"mediaType": "application/vcard+json",
"extensions": [".json"]
},
{
"mediaType": "application/jscontact+json",
"extensions": [".json"]
}
],
"produces": [
{
"mediaType": "text/vcard",
"extensions": [".vcf"]
},
{
"mediaType": "application/vcard+json",
"extensions": [".json"]
},
{
"mediaType": "application/jscontact+json",
"extensions": [".json"]
}
]
},
"capabilities": {
"required": [],
"optional": []
},
"privacy": {
"processing": "local",
"fileUploads": true,
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0";
export const APP_VERSION = "0.2.0";
+2 -2
View File
@@ -37,7 +37,7 @@ test("preserves contacts on parse failure and generates QR locally", async ({
await page.getByLabel("vCard source").fill("not a card");
await page.getByRole("button", { name: "Parse safely" }).click();
await expect(page.getByRole("alert")).toContainText("No complete");
await page.getByRole("tab", { name: "Contact QR" }).click();
await page.getByRole("button", { name: "Contact QR" }).click();
await page.getByRole("button", { name: "Generate local QR preview" }).click();
await expect(page.getByRole("img", { name: /QR code for/u })).toBeVisible();
expect(external).toEqual([]);
@@ -53,7 +53,7 @@ test("serves release identity and hardened headers", async ({ request }) => {
const manifest = await request.get("/deep/nested/contact/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.contact-tools",
version: "0.1.0",
version: "0.2.0",
entry: "./",
privacy: { processing: "local", telemetry: false },
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/contact/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+1 -1
View File
@@ -14,7 +14,7 @@ describe("Contact Tools", () => {
await screen.findByRole("heading", { name: "Contact Tools" }),
).toBeVisible();
await userEvent.click(
await screen.findByRole("tab", { name: "Duplicates" }),
await screen.findByRole("button", { name: "Duplicates" }),
);
expect(screen.getByText(/same normalized email/u)).toBeVisible();
expect(screen.getByText("Browser-local")).toBeVisible();
+76
View File
@@ -10,6 +10,12 @@ import {
serializeVCards,
} from "../../src/contact/model";
import { contactQrPayload } from "../../src/contact/qr";
import {
contactsFromJCard,
contactsFromJSContact,
contactsToJCard,
contactsToJSContact,
} from "../../src/contact/interchange";
const twoCards = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\nEMAIL;TYPE=HOME,PREF:ada@example.test\r\nNOTE:one\r\n two\r\nEND:VCARD\r\nBEGIN:VCARD\r\nVERSION:4.0\r\nFN:Ada L. Lovelace\r\nN:Lovelace;Ada;L.;;\r\nEMAIL;PREF=1:ADA@example.test\r\nEND:VCARD\r\n`;
@@ -37,6 +43,30 @@ describe("vCard model", () => {
expect(parseVCards(output).contacts[0]?.formattedName).toBe("Ada Lovelace");
});
it("decodes vCard 2.1 values and preserves unknown grouped properties", () => {
const source = `BEGIN:VCARD\r\nVERSION:2.1\r\nFN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Andr=C3=A9=\r\n Example\r\nN:Example;André;;;\r\nitem1.EMAIL;TYPE=HOME:andre@example.test\r\nitem1.X-ABLabel:_$!<Home>!$_\r\nPHOTO;ENCODING=BASE64;TYPE=JPEG:/9j/2Q==\r\nEND:VCARD\r\n`;
const parsed = parseVCards(source);
expect(parsed.contacts[0]).toMatchObject({
version: "2.1",
formattedName: "André Example",
});
expect(parsed.contacts[0]?.emails[0]?.group).toBe("item1");
const label = parsed.contacts[0]?.preservedProperties.find(
(property) => property.name === "X-ABLABEL",
);
expect(label).toMatchObject({ group: "item1", rawValue: "_$!<Home>!$_" });
const photo = parsed.contacts[0]?.preservedProperties.find(
(property) => property.name === "PHOTO",
);
expect([...photo!.decodedBytes!]).toEqual([0xff, 0xd8, 0xff, 0xd9]);
const output = serializeVCards(parsed.contacts, "2.1");
expect(output).toContain("item1.X-ABLABEL:_$!<Home>!$_");
expect(output).toContain("PHOTO;ENCODING=BASE64;TYPE=JPEG:/9j/2Q==");
expect(parseVCards(output).contacts[0]?.formattedName).toBe(
"André Example",
);
});
it("rejects incomplete and oversized inputs", () => {
expect(() => parseVCards("BEGIN:VCARD\nFN:No end")).toThrow(/No complete/u);
expect(() => parseVCards("x".repeat(2 * 1024 * 1024 + 1))).toThrow(
@@ -131,4 +161,50 @@ describe("vCard model", () => {
contactQrPayload({ ...contact, note: "x".repeat(2_300) }),
).toThrow(/too large/u);
});
it("maps broader RFC 6350 fields and preserves preference parameters", () => {
const source = `BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Example Person\r\nN:Person;Example;;;\r\nKIND:individual\r\nNICKNAME:Ex,E.P.\r\nROLE:Architect\r\nANNIVERSARY:2020-02-03\r\nGENDER:F\r\nLANG;PREF=1:de\r\nTZ:Europe/Berlin\r\nGEO:geo:52.5,13.4\r\nIMPP;TYPE=work:xmpp:example@example.test\r\nRELATED;TYPE=friend:urn:uuid:friend\r\nMEMBER:urn:uuid:group-member\r\nPHOTO;VALUE=uri:https://example.test/photo.jpg\r\nEND:VCARD\r\n`;
const contact = parseVCards(source).contacts[0]!;
expect(contact).toMatchObject({
kind: "individual",
nicknames: ["Ex", "E.P."],
role: "Architect",
anniversary: "2020-02-03",
gender: "F",
});
expect(contact.languages[0]).toMatchObject({
value: "de",
preferred: true,
});
expect(contact.media[0]).toMatchObject({ kind: "PHOTO" });
const roundTrip = parseVCards(serializeVCards([contact])).contacts[0]!;
expect(roundTrip.instantMessaging[0]?.value).toBe(
"xmpp:example@example.test",
);
expect(roundTrip.members[0]?.value).toBe("urn:uuid:group-member");
});
it("round-trips structured jCard and focused JSContact cards", () => {
const contact = parseVCards(twoCards).contacts[0]!;
const jcard = contactsToJCard([contact]);
expect(jcard[0]?.[0]).toBe("vcard");
expect(contactsFromJCard(jcard)[0]).toMatchObject({
formattedName: "Ada Lovelace",
familyName: "Lovelace",
givenName: "Ada",
});
const jscontact = contactsToJSContact([contact]);
expect(jscontact.data[0]).toMatchObject({
"@type": "Card",
version: "1.0",
});
const imported = contactsFromJSContact(jscontact.data)[0]!;
expect(imported).toMatchObject({
formattedName: "Ada Lovelace",
familyName: "Lovelace",
givenName: "Ada",
});
expect(imported.emails[0]?.value).toBe("ada@example.test");
});
});