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

This commit is contained in:
2026-09-02 11:45:06 +02:00
parent 991bebd0d9
commit eb7ff7ae8f
27 changed files with 1356 additions and 110 deletions
+2 -2
View File
@@ -37,7 +37,7 @@ test("preserves contacts on parse failure and generates QR locally", async ({
await page.getByLabel("vCard source").fill("not a card");
await page.getByRole("button", { name: "Parse safely" }).click();
await expect(page.getByRole("alert")).toContainText("No complete");
await page.getByRole("tab", { name: "Contact QR" }).click();
await page.getByRole("button", { name: "Contact QR" }).click();
await page.getByRole("button", { name: "Generate local QR preview" }).click();
await expect(page.getByRole("img", { name: /QR code for/u })).toBeVisible();
expect(external).toEqual([]);
@@ -53,7 +53,7 @@ test("serves release identity and hardened headers", async ({ request }) => {
const manifest = await request.get("/deep/nested/contact/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.contact-tools",
version: "0.1.0",
version: "0.2.0",
entry: "./",
privacy: { processing: "local", telemetry: false },
});
+18
View File
@@ -0,0 +1,18 @@
import { expect, test } from "@playwright/test";
test("keeps the primary workspace inside a narrow viewport", async ({
page,
}) => {
await page.goto("/deep/nested/contact/");
await expect(page.locator("main").first()).toBeVisible();
await expect(
page.locator("main .loading, main .workbench-loading"),
).toHaveCount(0);
const widths = await page.evaluate(() => ({
content: document.documentElement.scrollWidth,
viewport: document.documentElement.clientWidth,
}));
expect(widths.viewport).toBeLessThanOrEqual(430);
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
});
+1 -1
View File
@@ -14,7 +14,7 @@ describe("Contact Tools", () => {
await screen.findByRole("heading", { name: "Contact Tools" }),
).toBeVisible();
await userEvent.click(
await screen.findByRole("tab", { name: "Duplicates" }),
await screen.findByRole("button", { name: "Duplicates" }),
);
expect(screen.getByText(/same normalized email/u)).toBeVisible();
expect(screen.getByText("Browser-local")).toBeVisible();
+76
View File
@@ -10,6 +10,12 @@ import {
serializeVCards,
} from "../../src/contact/model";
import { contactQrPayload } from "../../src/contact/qr";
import {
contactsFromJCard,
contactsFromJSContact,
contactsToJCard,
contactsToJSContact,
} from "../../src/contact/interchange";
const twoCards = `BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\nEMAIL;TYPE=HOME,PREF:ada@example.test\r\nNOTE:one\r\n two\r\nEND:VCARD\r\nBEGIN:VCARD\r\nVERSION:4.0\r\nFN:Ada L. Lovelace\r\nN:Lovelace;Ada;L.;;\r\nEMAIL;PREF=1:ADA@example.test\r\nEND:VCARD\r\n`;
@@ -37,6 +43,30 @@ describe("vCard model", () => {
expect(parseVCards(output).contacts[0]?.formattedName).toBe("Ada Lovelace");
});
it("decodes vCard 2.1 values and preserves unknown grouped properties", () => {
const source = `BEGIN:VCARD\r\nVERSION:2.1\r\nFN;CHARSET=UTF-8;ENCODING=QUOTED-PRINTABLE:Andr=C3=A9=\r\n Example\r\nN:Example;André;;;\r\nitem1.EMAIL;TYPE=HOME:andre@example.test\r\nitem1.X-ABLabel:_$!<Home>!$_\r\nPHOTO;ENCODING=BASE64;TYPE=JPEG:/9j/2Q==\r\nEND:VCARD\r\n`;
const parsed = parseVCards(source);
expect(parsed.contacts[0]).toMatchObject({
version: "2.1",
formattedName: "André Example",
});
expect(parsed.contacts[0]?.emails[0]?.group).toBe("item1");
const label = parsed.contacts[0]?.preservedProperties.find(
(property) => property.name === "X-ABLABEL",
);
expect(label).toMatchObject({ group: "item1", rawValue: "_$!<Home>!$_" });
const photo = parsed.contacts[0]?.preservedProperties.find(
(property) => property.name === "PHOTO",
);
expect([...photo!.decodedBytes!]).toEqual([0xff, 0xd8, 0xff, 0xd9]);
const output = serializeVCards(parsed.contacts, "2.1");
expect(output).toContain("item1.X-ABLABEL:_$!<Home>!$_");
expect(output).toContain("PHOTO;ENCODING=BASE64;TYPE=JPEG:/9j/2Q==");
expect(parseVCards(output).contacts[0]?.formattedName).toBe(
"André Example",
);
});
it("rejects incomplete and oversized inputs", () => {
expect(() => parseVCards("BEGIN:VCARD\nFN:No end")).toThrow(/No complete/u);
expect(() => parseVCards("x".repeat(2 * 1024 * 1024 + 1))).toThrow(
@@ -131,4 +161,50 @@ describe("vCard model", () => {
contactQrPayload({ ...contact, note: "x".repeat(2_300) }),
).toThrow(/too large/u);
});
it("maps broader RFC 6350 fields and preserves preference parameters", () => {
const source = `BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Example Person\r\nN:Person;Example;;;\r\nKIND:individual\r\nNICKNAME:Ex,E.P.\r\nROLE:Architect\r\nANNIVERSARY:2020-02-03\r\nGENDER:F\r\nLANG;PREF=1:de\r\nTZ:Europe/Berlin\r\nGEO:geo:52.5,13.4\r\nIMPP;TYPE=work:xmpp:example@example.test\r\nRELATED;TYPE=friend:urn:uuid:friend\r\nMEMBER:urn:uuid:group-member\r\nPHOTO;VALUE=uri:https://example.test/photo.jpg\r\nEND:VCARD\r\n`;
const contact = parseVCards(source).contacts[0]!;
expect(contact).toMatchObject({
kind: "individual",
nicknames: ["Ex", "E.P."],
role: "Architect",
anniversary: "2020-02-03",
gender: "F",
});
expect(contact.languages[0]).toMatchObject({
value: "de",
preferred: true,
});
expect(contact.media[0]).toMatchObject({ kind: "PHOTO" });
const roundTrip = parseVCards(serializeVCards([contact])).contacts[0]!;
expect(roundTrip.instantMessaging[0]?.value).toBe(
"xmpp:example@example.test",
);
expect(roundTrip.members[0]?.value).toBe("urn:uuid:group-member");
});
it("round-trips structured jCard and focused JSContact cards", () => {
const contact = parseVCards(twoCards).contacts[0]!;
const jcard = contactsToJCard([contact]);
expect(jcard[0]?.[0]).toBe("vcard");
expect(contactsFromJCard(jcard)[0]).toMatchObject({
formattedName: "Ada Lovelace",
familyName: "Lovelace",
givenName: "Ada",
});
const jscontact = contactsToJSContact([contact]);
expect(jscontact.data[0]).toMatchObject({
"@type": "Card",
version: "1.0",
});
const imported = contactsFromJSContact(jscontact.data)[0]!;
expect(imported).toMatchObject({
formattedName: "Ada Lovelace",
familyName: "Lovelace",
givenName: "Ada",
});
expect(imported.emails[0]?.value).toBe("ada@example.test");
});
});