@@ -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
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Added the initial local-first Contact Tools workbench.
|
- Added the initial local-first Contact Tools workbench.
|
||||||
|
|||||||
@@ -6,15 +6,18 @@ Contact Tools is a standalone local-first application in the [add·ideas Toolbox
|
|||||||
|
|
||||||
## Version 0.1 scope
|
## 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
## Development
|
||||||
|
|
||||||
@@ -28,7 +31,7 @@ npm run test:browser
|
|||||||
|
|
||||||
## Release
|
## 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
|
## Licence
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Corresponding source
|
# 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`.
|
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Third-party notices
|
# 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 |
|
| Package | Pinned version | Declared licence |
|
||||||
| -------------------------------- | -------------: | ---------------- |
|
| -------------------------------- | -------------: | ---------------- |
|
||||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
|
||||||
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
|
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
|
||||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
|
||||||
| `qrcode-generator` | 2.0.4 | MIT |
|
| `qrcode-generator` | 2.0.4 | MIT |
|
||||||
| `react` | 19.2.8 | MIT |
|
| `react` | 19.2.8 | MIT |
|
||||||
| `react-dom` | 19.2.8 | MIT |
|
| `react-dom` | 19.2.8 | MIT |
|
||||||
|
|||||||
@@ -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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
Generated
+22
-23
@@ -1,23 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "contact-tools",
|
"name": "contact-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "contact-tools",
|
"name": "contact-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"qrcode-generator": "2.0.4",
|
"qrcode-generator": "2.0.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
@@ -43,24 +43,24 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-contract": {
|
"node_modules/@add-ideas/toolbox-contract": {
|
||||||
"version": "0.2.3",
|
"version": "0.3.0",
|
||||||
"license": "Apache-2.0",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||||
"engines": {
|
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||||
"node": ">=20"
|
"license": "Apache-2.0"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-helpers": {
|
"node_modules/@add-ideas/toolbox-helpers": {
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "GPL-3.0-or-later",
|
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
|
||||||
"engines": {
|
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||||
"node": ">=22"
|
"license": "GPL-3.0-or-later"
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
"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",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=18 <20",
|
"react": ">=18 <20",
|
||||||
@@ -68,17 +68,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@add-ideas/toolbox-testkit": {
|
"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,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3"
|
"@add-ideas/toolbox-contract": "0.3.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"toolbox-check": "dist/cli.js"
|
"toolbox-check": "dist/cli.js"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@adobe/css-tools": {
|
"node_modules/@adobe/css-tools": {
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "contact-tools",
|
"name": "contact-tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect, convert, and reconcile contacts locally in the browser.",
|
"description": "Inspect, convert, and reconcile contacts locally in the browser.",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"author": "Albrecht Degering",
|
"author": "Albrecht Degering",
|
||||||
@@ -39,15 +39,15 @@
|
|||||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@add-ideas/toolbox-contract": "0.2.3",
|
"@add-ideas/toolbox-contract": "0.3.0",
|
||||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||||
"qrcode-generator": "2.0.4",
|
"qrcode-generator": "2.0.4",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8"
|
"react-dom": "19.2.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@playwright/test": "1.62.1",
|
"@playwright/test": "1.62.1",
|
||||||
"@testing-library/jest-dom": "6.9.1",
|
"@testing-library/jest-dom": "6.9.1",
|
||||||
|
|||||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
|||||||
timeout: 180_000,
|
timeout: 180_000,
|
||||||
},
|
},
|
||||||
projects: [
|
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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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
|
## 0.1.0 - 2026-09-01
|
||||||
|
|
||||||
- Added the initial local-first Contact Tools workbench.
|
- Added the initial local-first Contact Tools workbench.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
==============================================================================
|
==============================================================================
|
||||||
@add-ideas/toolbox-contract@0.2.3
|
@add-ideas/toolbox-contract@0.3.0
|
||||||
Declared licence: Apache-2.0
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: GPL-3.0-or-later
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- 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
|
Declared licence: Apache-2.0
|
||||||
==============================================================================
|
==============================================================================
|
||||||
--- LICENSE ---
|
--- LICENSE ---
|
||||||
|
|||||||
+7
-4
@@ -6,15 +6,18 @@ Contact Tools is a standalone local-first application in the [add·ideas Toolbox
|
|||||||
|
|
||||||
## Version 0.1 scope
|
## 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
|
- 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
|
- 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
|
- 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
|
- 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
|
- 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
|
## Development
|
||||||
|
|
||||||
@@ -28,7 +31,7 @@ npm run test:browser
|
|||||||
|
|
||||||
## Release
|
## 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
|
## Licence
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
# Corresponding source
|
# 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`.
|
Build with Node.js 22, npm 11, `npm ci`, and `npm run release:artifact`.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Third-party notices
|
# 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 |
|
| Package | Pinned version | Declared licence |
|
||||||
| -------------------------------- | -------------: | ---------------- |
|
| -------------------------------- | -------------: | ---------------- |
|
||||||
| `@add-ideas/toolbox-contract` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-contract` | 0.3.0 | Apache-2.0 |
|
||||||
| `@add-ideas/toolbox-helpers` | 0.1.0 | GPL-3.0-or-later |
|
| `@add-ideas/toolbox-helpers` | 0.2.0 | GPL-3.0-or-later |
|
||||||
| `@add-ideas/toolbox-shell-react` | 0.2.3 | Apache-2.0 |
|
| `@add-ideas/toolbox-shell-react` | 0.3.0 | Apache-2.0 |
|
||||||
| `qrcode-generator` | 2.0.4 | MIT |
|
| `qrcode-generator` | 2.0.4 | MIT |
|
||||||
| `react` | 19.2.8 | MIT |
|
| `react` | 19.2.8 | MIT |
|
||||||
| `react-dom` | 19.2.8 | MIT |
|
| `react-dom` | 19.2.8 | MIT |
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
@@ -1,5 +1,5 @@
|
|||||||
const CACHE_PREFIX = "contact-tools-shell-";
|
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"];
|
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||||
self.addEventListener("install", (event) => {
|
self.addEventListener("install", (event) => {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
|
|||||||
+14
-1
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.contact-tools",
|
"id": "de.add-ideas.contact-tools",
|
||||||
"name": "Contact Tools",
|
"name": "Contact Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect, convert, and reconcile contacts locally in the browser.",
|
"description": "Inspect, convert, and reconcile contacts locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -21,6 +21,19 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { useMemo, useState } from "react";
|
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 {
|
import {
|
||||||
CONTACT_FIELDS,
|
CONTACT_FIELDS,
|
||||||
contactsFromCsv,
|
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`;
|
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> = {
|
const FIELD_LABELS: Record<ContactField, string> = {
|
||||||
formattedName: "Full name",
|
formattedName: "Full name",
|
||||||
givenName: "Given name",
|
givenName: "Given name",
|
||||||
@@ -64,7 +73,17 @@ function ContactEditor({
|
|||||||
}) {
|
}) {
|
||||||
const set = (field: keyof Contact, value: string) =>
|
const set = (field: keyof Contact, value: string) =>
|
||||||
onChange({ ...contact, [field]: value });
|
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 current = contact[kind];
|
||||||
const next = current.length
|
const next = current.length
|
||||||
? current.map((item, index) => (index ? item : { ...item, value }))
|
? current.map((item, index) => (index ? item : { ...item, value }))
|
||||||
@@ -110,6 +129,42 @@ function ContactEditor({
|
|||||||
onChange={(event) => set("title", event.target.value)}
|
onChange={(event) => set("title", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="field">
|
||||||
<span>Birthday</span>
|
<span>Birthday</span>
|
||||||
<input
|
<input
|
||||||
@@ -118,6 +173,21 @@ function ContactEditor({
|
|||||||
placeholder="YYYY-MM-DD or vCard value"
|
placeholder="YYYY-MM-DD or vCard value"
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="field">
|
||||||
<span>Primary email</span>
|
<span>Primary email</span>
|
||||||
<input
|
<input
|
||||||
@@ -140,6 +210,40 @@ function ContactEditor({
|
|||||||
onChange={(event) => setPrimary("urls", event.target.value)}
|
onChange={(event) => setPrimary("urls", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="field wide">
|
||||||
<span>Categories (comma-separated)</span>
|
<span>Categories (comma-separated)</span>
|
||||||
<input
|
<input
|
||||||
@@ -155,6 +259,13 @@ function ContactEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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">
|
<label className="field wide">
|
||||||
<span>Note</span>
|
<span>Note</span>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -180,6 +291,11 @@ export function Workbench() {
|
|||||||
const [headers, setHeaders] = useState<string[]>([]);
|
const [headers, setHeaders] = useState<string[]>([]);
|
||||||
const [mapping, setMapping] = useState<CsvMapping>({});
|
const [mapping, setMapping] = useState<CsvMapping>({});
|
||||||
const [qr, setQr] = useState("");
|
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(
|
const duplicateReport = useMemo(
|
||||||
() => analyzeDuplicates(contacts),
|
() => analyzeDuplicates(contacts),
|
||||||
[contacts],
|
[contacts],
|
||||||
@@ -261,6 +377,8 @@ export function Workbench() {
|
|||||||
setHeaders([]);
|
setHeaders([]);
|
||||||
setMapping({});
|
setMapping({});
|
||||||
setQr("");
|
setQr("");
|
||||||
|
setJsonSource("");
|
||||||
|
setJsonWarnings([]);
|
||||||
setRevealed(false);
|
setRevealed(false);
|
||||||
setError("");
|
setError("");
|
||||||
};
|
};
|
||||||
@@ -268,6 +386,44 @@ export function Workbench() {
|
|||||||
setContacts((values) =>
|
setContacts((values) =>
|
||||||
values.map((contact, index) => (index === selected ? next : contact)),
|
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 (
|
return (
|
||||||
<main className="workbench">
|
<main className="workbench">
|
||||||
@@ -310,19 +466,20 @@ export function Workbench() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<nav className="workspace-tabs" aria-label="Contact workspaces">
|
<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
|
<button
|
||||||
key={value}
|
key={value}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
aria-pressed={tab === value}
|
||||||
aria-selected={tab === value}
|
|
||||||
onClick={() => setTab(value)}
|
onClick={() => setTab(value)}
|
||||||
>
|
>
|
||||||
{value === "qr"
|
{value === "qr"
|
||||||
? "Contact QR"
|
? "Contact QR"
|
||||||
: value[0]!.toUpperCase() + value.slice(1)}
|
: value[0]!.toUpperCase() + value.slice(1)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{tab === "contacts" && (
|
{tab === "contacts" && (
|
||||||
@@ -334,7 +491,7 @@ export function Workbench() {
|
|||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Input</p>
|
<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>
|
</div>
|
||||||
<label className="button file-button">
|
<label className="button file-button">
|
||||||
Open .vcf
|
Open .vcf
|
||||||
@@ -365,6 +522,7 @@ export function Workbench() {
|
|||||||
>
|
>
|
||||||
<option value="4.0">Canonical vCard 4.0</option>
|
<option value="4.0">Canonical vCard 4.0</option>
|
||||||
<option value="3.0">Canonical vCard 3.0</option>
|
<option value="3.0">Canonical vCard 3.0</option>
|
||||||
|
<option value="2.1">Compatible vCard 2.1</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -521,6 +679,70 @@ export function Workbench() {
|
|||||||
</div>
|
</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" && (
|
{tab === "duplicates" && (
|
||||||
<section className="panel workspace">
|
<section className="panel workspace">
|
||||||
<div className="panel-heading">
|
<div className="panel-heading">
|
||||||
|
|||||||
@@ -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 1–2,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 1–2,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
@@ -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 {
|
export interface ContactValue {
|
||||||
value: string;
|
value: string;
|
||||||
types: string[];
|
types: string[];
|
||||||
preferred: boolean;
|
preferred: boolean;
|
||||||
|
group?: string;
|
||||||
|
parameters?: VCardParameter[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AddressValue extends ContactValue {
|
export interface AddressValue extends ContactValue {
|
||||||
@@ -16,6 +35,14 @@ export interface AddressValue extends ContactValue {
|
|||||||
country: string;
|
country: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ContactMedia {
|
||||||
|
kind: "PHOTO" | "LOGO" | "KEY";
|
||||||
|
value: string;
|
||||||
|
group?: string;
|
||||||
|
parameters: VCardParameter[];
|
||||||
|
decodedBytes?: Uint8Array<ArrayBuffer>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Contact {
|
export interface Contact {
|
||||||
id: string;
|
id: string;
|
||||||
version: VCardVersion;
|
version: VCardVersion;
|
||||||
@@ -27,14 +54,27 @@ export interface Contact {
|
|||||||
honorificSuffix: string;
|
honorificSuffix: string;
|
||||||
organization: string;
|
organization: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
kind: string;
|
||||||
|
nicknames: string[];
|
||||||
|
role: string;
|
||||||
|
anniversary: string;
|
||||||
|
gender: string;
|
||||||
emails: ContactValue[];
|
emails: ContactValue[];
|
||||||
phones: ContactValue[];
|
phones: ContactValue[];
|
||||||
addresses: AddressValue[];
|
addresses: AddressValue[];
|
||||||
urls: ContactValue[];
|
urls: ContactValue[];
|
||||||
|
languages: ContactValue[];
|
||||||
|
timezones: ContactValue[];
|
||||||
|
geos: ContactValue[];
|
||||||
|
instantMessaging: ContactValue[];
|
||||||
|
related: ContactValue[];
|
||||||
|
members: ContactValue[];
|
||||||
|
media: ContactMedia[];
|
||||||
birthday: string;
|
birthday: string;
|
||||||
note: string;
|
note: string;
|
||||||
uid: string;
|
uid: string;
|
||||||
categories: string[];
|
categories: string[];
|
||||||
|
preservedProperties: PreservedVCardProperty[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ParseWarning {
|
export interface ParseWarning {
|
||||||
@@ -87,14 +127,27 @@ function emptyContact(index: number): Contact {
|
|||||||
honorificSuffix: "",
|
honorificSuffix: "",
|
||||||
organization: "",
|
organization: "",
|
||||||
title: "",
|
title: "",
|
||||||
|
kind: "individual",
|
||||||
|
nicknames: [],
|
||||||
|
role: "",
|
||||||
|
anniversary: "",
|
||||||
|
gender: "",
|
||||||
emails: [],
|
emails: [],
|
||||||
phones: [],
|
phones: [],
|
||||||
addresses: [],
|
addresses: [],
|
||||||
urls: [],
|
urls: [],
|
||||||
|
languages: [],
|
||||||
|
timezones: [],
|
||||||
|
geos: [],
|
||||||
|
instantMessaging: [],
|
||||||
|
related: [],
|
||||||
|
members: [],
|
||||||
|
media: [],
|
||||||
birthday: "",
|
birthday: "",
|
||||||
note: "",
|
note: "",
|
||||||
uid: "",
|
uid: "",
|
||||||
categories: [],
|
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.");
|
throw new Error("Contact input exceeds the 50,000-line limit.");
|
||||||
const logical: { text: string; line: number }[] = [];
|
const logical: { text: string; line: number }[] = [];
|
||||||
for (const [index, text] of physical.entries()) {
|
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);
|
logical[logical.length - 1]!.text += text.slice(1);
|
||||||
else logical.push({ text, line: index + 1 });
|
else logical.push({ text, line: index + 1 });
|
||||||
}
|
}
|
||||||
@@ -168,11 +230,41 @@ function unfold(source: string): { text: string; line: number }[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedProperty {
|
interface ParsedProperty {
|
||||||
|
group?: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
rawValue: string;
|
||||||
value: string;
|
value: string;
|
||||||
types: string[];
|
types: string[];
|
||||||
preferred: boolean;
|
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 {
|
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.");
|
throw new Error("A vCard property exceeds 64 KiB.");
|
||||||
const tokens = splitEscaped(left, ";");
|
const tokens = splitEscaped(left, ";");
|
||||||
const rawName = tokens.shift() ?? "";
|
const rawName = tokens.shift() ?? "";
|
||||||
|
const group = rawName.includes(".")
|
||||||
|
? rawName.slice(0, rawName.lastIndexOf("."))
|
||||||
|
: undefined;
|
||||||
const name = (
|
const name = (
|
||||||
rawName.includes(".")
|
rawName.includes(".")
|
||||||
? rawName.slice(rawName.lastIndexOf(".") + 1)
|
? rawName.slice(rawName.lastIndexOf(".") + 1)
|
||||||
@@ -191,27 +286,54 @@ function parseProperty(line: string): ParsedProperty | null {
|
|||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
const types = new Set<string>();
|
const types = new Set<string>();
|
||||||
let preferred = false;
|
let preferred = false;
|
||||||
let unsupportedEncoding = false;
|
const parameters: VCardParameter[] = [];
|
||||||
|
let encoding: string | undefined;
|
||||||
|
let charset = "utf-8";
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
const equals = token.indexOf("=");
|
const equals = token.indexOf("=");
|
||||||
const key = (equals < 0 ? "TYPE" : token.slice(0, equals)).toUpperCase();
|
const key = (equals < 0 ? "TYPE" : token.slice(0, equals)).toUpperCase();
|
||||||
const value =
|
const value =
|
||||||
equals < 0 ? token : token.slice(equals + 1).replace(/^"|"$/gu, "");
|
equals < 0 ? token : token.slice(equals + 1).replace(/^"|"$/gu, "");
|
||||||
|
parameters.push({ name: key, value });
|
||||||
if (key === "TYPE")
|
if (key === "TYPE")
|
||||||
for (const type of value.split(","))
|
for (const type of value.split(","))
|
||||||
if (type.trim()) types.add(type.trim().toLowerCase());
|
if (type.trim()) types.add(type.trim().toLowerCase());
|
||||||
if (key === "PREF" && value !== "0" && value.toLowerCase() !== "false")
|
if (key === "PREF" && value !== "0" && value.toLowerCase() !== "false")
|
||||||
preferred = true;
|
preferred = true;
|
||||||
if (key === "ENCODING" && /^(?:b|base64|quoted-printable)$/iu.test(value))
|
if (key === "ENCODING") encoding = value.toLowerCase();
|
||||||
unsupportedEncoding = true;
|
if (key === "CHARSET") charset = value;
|
||||||
}
|
}
|
||||||
if (types.has("pref")) preferred = true;
|
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 {
|
return {
|
||||||
|
...(group ? { group } : {}),
|
||||||
name,
|
name,
|
||||||
value: unescapeValue(rawValue),
|
rawValue: decodedRawValue,
|
||||||
|
value: unescapeValue(decodedRawValue),
|
||||||
types: [...types],
|
types: [...types],
|
||||||
preferred,
|
preferred,
|
||||||
unsupportedEncoding,
|
parameters,
|
||||||
|
...(encoding ? { encoding } : {}),
|
||||||
|
...(decodedBytes ? { decodedBytes } : {}),
|
||||||
|
...(decodingError ? { decodingError } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,6 +343,25 @@ function parseCard(
|
|||||||
warnings: ParseWarning[],
|
warnings: ParseWarning[],
|
||||||
): Contact {
|
): Contact {
|
||||||
const contact = emptyContact(cardIndex);
|
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;
|
let sawName = false;
|
||||||
for (const entry of lines) {
|
for (const entry of lines) {
|
||||||
const property = parseProperty(entry.text);
|
const property = parseProperty(entry.text);
|
||||||
@@ -233,18 +374,18 @@ function parseCard(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (property.unsupportedEncoding) {
|
if (property.decodingError) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
card: cardIndex,
|
card: cardIndex,
|
||||||
line: entry.line,
|
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;
|
const value = property.value;
|
||||||
switch (property.name) {
|
switch (property.name) {
|
||||||
case "VERSION":
|
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
|
else
|
||||||
warnings.push({
|
warnings.push({
|
||||||
card: cardIndex,
|
card: cardIndex,
|
||||||
@@ -256,10 +397,7 @@ function parseCard(
|
|||||||
contact.formattedName ||= value;
|
contact.formattedName ||= value;
|
||||||
break;
|
break;
|
||||||
case "N": {
|
case "N": {
|
||||||
const parts = splitEscaped(
|
const parts = splitEscaped(property.rawValue, ";").map(unescapeValue);
|
||||||
entry.text.slice(findValueColon(entry.text) + 1),
|
|
||||||
";",
|
|
||||||
).map(unescapeValue);
|
|
||||||
[
|
[
|
||||||
contact.familyName,
|
contact.familyName,
|
||||||
contact.givenName,
|
contact.givenName,
|
||||||
@@ -277,21 +415,39 @@ function parseCard(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "ORG":
|
case "ORG":
|
||||||
contact.organization ||= splitEscaped(
|
contact.organization ||= splitEscaped(property.rawValue, ";")
|
||||||
entry.text.slice(findValueColon(entry.text) + 1),
|
|
||||||
";",
|
|
||||||
)
|
|
||||||
.map(unescapeValue)
|
.map(unescapeValue)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
break;
|
break;
|
||||||
case "TITLE":
|
case "TITLE":
|
||||||
contact.title ||= value;
|
contact.title ||= value;
|
||||||
break;
|
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":
|
case "EMAIL":
|
||||||
contact.emails.push({
|
contact.emails.push({
|
||||||
value: value.replace(/^mailto:/iu, ""),
|
value: value.replace(/^mailto:/iu, ""),
|
||||||
types: property.types,
|
types: property.types,
|
||||||
preferred: property.preferred,
|
preferred: property.preferred,
|
||||||
|
...(property.group ? { group: property.group } : {}),
|
||||||
|
parameters: property.parameters,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "TEL":
|
case "TEL":
|
||||||
@@ -299,6 +455,8 @@ function parseCard(
|
|||||||
value: value.replace(/^tel:/iu, ""),
|
value: value.replace(/^tel:/iu, ""),
|
||||||
types: property.types,
|
types: property.types,
|
||||||
preferred: property.preferred,
|
preferred: property.preferred,
|
||||||
|
...(property.group ? { group: property.group } : {}),
|
||||||
|
parameters: property.parameters,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "URL":
|
case "URL":
|
||||||
@@ -306,17 +464,50 @@ function parseCard(
|
|||||||
value,
|
value,
|
||||||
types: property.types,
|
types: property.types,
|
||||||
preferred: property.preferred,
|
preferred: property.preferred,
|
||||||
|
...(property.group ? { group: property.group } : {}),
|
||||||
|
parameters: property.parameters,
|
||||||
});
|
});
|
||||||
break;
|
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": {
|
case "ADR": {
|
||||||
const parts = splitEscaped(
|
const parts = splitEscaped(property.rawValue, ";").map(unescapeValue);
|
||||||
entry.text.slice(findValueColon(entry.text) + 1),
|
|
||||||
";",
|
|
||||||
).map(unescapeValue);
|
|
||||||
contact.addresses.push({
|
contact.addresses.push({
|
||||||
value,
|
value,
|
||||||
types: property.types,
|
types: property.types,
|
||||||
preferred: property.preferred,
|
preferred: property.preferred,
|
||||||
|
...(property.group ? { group: property.group } : {}),
|
||||||
|
parameters: property.parameters,
|
||||||
poBox: parts[0] ?? "",
|
poBox: parts[0] ?? "",
|
||||||
extended: parts[1] ?? "",
|
extended: parts[1] ?? "",
|
||||||
street: parts[2] ?? "",
|
street: parts[2] ?? "",
|
||||||
@@ -338,7 +529,7 @@ function parseCard(
|
|||||||
break;
|
break;
|
||||||
case "CATEGORIES":
|
case "CATEGORIES":
|
||||||
contact.categories.push(
|
contact.categories.push(
|
||||||
...splitEscaped(entry.text.slice(findValueColon(entry.text) + 1), ",")
|
...splitEscaped(property.rawValue, ",")
|
||||||
.map(unescapeValue)
|
.map(unescapeValue)
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
);
|
);
|
||||||
@@ -347,10 +538,11 @@ function parseCard(
|
|||||||
case "END":
|
case "END":
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
contact.preservedProperties.push(preserve(property, entry.text));
|
||||||
warnings.push({
|
warnings.push({
|
||||||
card: cardIndex,
|
card: cardIndex,
|
||||||
line: entry.line,
|
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[] = [];
|
const parts: string[] = [];
|
||||||
if (types.length) parts.push(`TYPE=${types.join(",")}`);
|
if (types.length) parts.push(`TYPE=${types.join(",")}`);
|
||||||
if (item.preferred) parts.push(version === "4.0" ? "PREF=1" : "TYPE=PREF");
|
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(";")}` : "";
|
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(
|
export function serializeVCards(
|
||||||
contacts: readonly Contact[],
|
contacts: readonly Contact[],
|
||||||
version: VCardVersion = "4.0",
|
version: VCardVersion = "4.0",
|
||||||
@@ -484,21 +705,43 @@ export function serializeVCards(
|
|||||||
if (contact.organization)
|
if (contact.organization)
|
||||||
lines.push(`ORG:${escapeValue(contact.organization)}`);
|
lines.push(`ORG:${escapeValue(contact.organization)}`);
|
||||||
if (contact.title) lines.push(`TITLE:${escapeValue(contact.title)}`);
|
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)
|
for (const item of contact.emails)
|
||||||
lines.push(
|
lines.push(
|
||||||
`EMAIL${parameters(item, version)}:${escapeValue(item.value)}`,
|
`${propertyPrefix(item, "EMAIL")}${parameters(item, version)}:${escapeValue(item.value)}`,
|
||||||
);
|
);
|
||||||
for (const item of contact.phones)
|
for (const item of contact.phones)
|
||||||
lines.push(
|
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)
|
for (const item of contact.addresses)
|
||||||
lines.push(
|
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)
|
for (const item of contact.urls)
|
||||||
lines.push(
|
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)
|
if (contact.birthday)
|
||||||
lines.push(`BDAY:${escapeValue(contact.birthday)}`);
|
lines.push(`BDAY:${escapeValue(contact.birthday)}`);
|
||||||
@@ -508,6 +751,8 @@ export function serializeVCards(
|
|||||||
lines.push(
|
lines.push(
|
||||||
`CATEGORIES:${contact.categories.map(escapeValue).join(",")}`,
|
`CATEGORIES:${contact.categories.map(escapeValue).join(",")}`,
|
||||||
);
|
);
|
||||||
|
for (const property of contact.preservedProperties)
|
||||||
|
lines.push(preservedPropertyLine(property));
|
||||||
lines.push("END:VCARD");
|
lines.push("END:VCARD");
|
||||||
return lines.map(foldLine);
|
return lines.map(foldLine);
|
||||||
})
|
})
|
||||||
@@ -854,6 +1099,22 @@ function uniqueValues(
|
|||||||
.map((item) => ({ ...item, types: [...item.types] }));
|
.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(
|
export function mergeContacts(
|
||||||
left: Contact,
|
left: Contact,
|
||||||
right: Contact,
|
right: Contact,
|
||||||
@@ -888,6 +1149,11 @@ export function mergeContacts(
|
|||||||
),
|
),
|
||||||
organization: choose("Organization", left.organization, right.organization),
|
organization: choose("Organization", left.organization, right.organization),
|
||||||
title: choose("Title", left.title, right.title),
|
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),
|
birthday: choose("Birthday", left.birthday, right.birthday),
|
||||||
note: [left.note, right.note]
|
note: [left.note, right.note]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -897,6 +1163,30 @@ export function mergeContacts(
|
|||||||
emails: uniqueValues([...left.emails, ...right.emails], "email"),
|
emails: uniqueValues([...left.emails, ...right.emails], "email"),
|
||||||
phones: uniqueValues([...left.phones, ...right.phones], "phone"),
|
phones: uniqueValues([...left.phones, ...right.phones], "phone"),
|
||||||
urls: uniqueValues([...left.urls, ...right.urls], "url"),
|
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]
|
addresses: [...left.addresses, ...right.addresses]
|
||||||
.filter(
|
.filter(
|
||||||
(item, index, values) =>
|
(item, index, values) =>
|
||||||
@@ -918,6 +1208,28 @@ export function mergeContacts(
|
|||||||
)
|
)
|
||||||
.map((item) => ({ ...item, types: [...item.types] })),
|
.map((item) => ({ ...item, types: [...item.types] })),
|
||||||
categories: [...new Set([...left.categories, ...right.categories])],
|
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 };
|
return { merged, conflicts };
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -161,7 +161,7 @@ textarea {
|
|||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 0.2rem;
|
padding-bottom: 0.2rem;
|
||||||
}
|
}
|
||||||
.workspace-tabs button[aria-selected="true"] {
|
.workspace-tabs button[aria-pressed="true"] {
|
||||||
border-color: var(--toolbox-accent);
|
border-color: var(--toolbox-accent);
|
||||||
background: var(--toolbox-accent);
|
background: var(--toolbox-accent);
|
||||||
color: var(--toolbox-accent-contrast);
|
color: var(--toolbox-accent-contrast);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "de.add-ideas.contact-tools",
|
"id": "de.add-ideas.contact-tools",
|
||||||
"name": "Contact Tools",
|
"name": "Contact Tools",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"description": "Inspect, convert, and reconcile contacts locally in the browser.",
|
"description": "Inspect, convert, and reconcile contacts locally in the browser.",
|
||||||
"entry": "./",
|
"entry": "./",
|
||||||
"icon": "./favicon.svg",
|
"icon": "./favicon.svg",
|
||||||
@@ -21,6 +21,40 @@
|
|||||||
"crossOriginIsolated": false,
|
"crossOriginIsolated": false,
|
||||||
"topLevelContext": 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": {
|
"privacy": {
|
||||||
"processing": "local",
|
"processing": "local",
|
||||||
"fileUploads": true,
|
"fileUploads": true,
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = "0.1.0";
|
export const APP_VERSION = "0.2.0";
|
||||||
|
|||||||
@@ -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.getByLabel("vCard source").fill("not a card");
|
||||||
await page.getByRole("button", { name: "Parse safely" }).click();
|
await page.getByRole("button", { name: "Parse safely" }).click();
|
||||||
await expect(page.getByRole("alert")).toContainText("No complete");
|
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 page.getByRole("button", { name: "Generate local QR preview" }).click();
|
||||||
await expect(page.getByRole("img", { name: /QR code for/u })).toBeVisible();
|
await expect(page.getByRole("img", { name: /QR code for/u })).toBeVisible();
|
||||||
expect(external).toEqual([]);
|
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");
|
const manifest = await request.get("/deep/nested/contact/toolbox-app.json");
|
||||||
await expect(manifest.json()).resolves.toMatchObject({
|
await expect(manifest.json()).resolves.toMatchObject({
|
||||||
id: "de.add-ideas.contact-tools",
|
id: "de.add-ideas.contact-tools",
|
||||||
version: "0.1.0",
|
version: "0.2.0",
|
||||||
entry: "./",
|
entry: "./",
|
||||||
privacy: { processing: "local", telemetry: false },
|
privacy: { processing: "local", telemetry: false },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -14,7 +14,7 @@ describe("Contact Tools", () => {
|
|||||||
await screen.findByRole("heading", { name: "Contact Tools" }),
|
await screen.findByRole("heading", { name: "Contact Tools" }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
await userEvent.click(
|
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(/same normalized email/u)).toBeVisible();
|
||||||
expect(screen.getByText("Browser-local")).toBeVisible();
|
expect(screen.getByText("Browser-local")).toBeVisible();
|
||||||
|
|||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
serializeVCards,
|
serializeVCards,
|
||||||
} from "../../src/contact/model";
|
} from "../../src/contact/model";
|
||||||
import { contactQrPayload } from "../../src/contact/qr";
|
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`;
|
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");
|
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", () => {
|
it("rejects incomplete and oversized inputs", () => {
|
||||||
expect(() => parseVCards("BEGIN:VCARD\nFN:No end")).toThrow(/No complete/u);
|
expect(() => parseVCards("BEGIN:VCARD\nFN:No end")).toThrow(/No complete/u);
|
||||||
expect(() => parseVCards("x".repeat(2 * 1024 * 1024 + 1))).toThrow(
|
expect(() => parseVCards("x".repeat(2 * 1024 * 1024 + 1))).toThrow(
|
||||||
@@ -131,4 +161,50 @@ describe("vCard model", () => {
|
|||||||
contactQrPayload({ ...contact, note: "x".repeat(2_300) }),
|
contactQrPayload({ ...contact, note: "x".repeat(2_300) }),
|
||||||
).toThrow(/too large/u);
|
).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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user