From 906818901ad61939b5703674d35a6d7dad5043b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Stabell?= Date: Fri, 29 Aug 2025 16:45:27 -0700 Subject: [PATCH] feat: Add full support for organization contacts (#43) - Converted vCard parsing from array-based to generator pattern (use less memory so bigger batch imports are possible) - Centralized name/slug creation into nameUtils module - Support organization contacts that don't require given/family names - slug/file names are now based on N components if they exist, with FN, NICKNAME, ORG, and UUID as fallbacks - Contacts that miss all of those fields are considered invalid and skipped on batch import - Update UI to handle batch imports with proper skip reporting - Improved error handling and reporting during VCF imports - Add test coverage for organization contacts, file and slug creation utilities This allows proper import/export of organization contacts (companies, businesses) that only have an ORG field without requiring artificial name fields, matching the behavior of standard contact applications. --- .gitignore | 3 + assets/docs/testing-strategy.md | 2 +- src/contacts/contactMdTemplate.ts | 14 +- src/contacts/vcard/parse.ts | 26 ++-- src/contacts/vcard/shared/ensureHasName.ts | 34 +++-- src/contacts/vcard/shared/nameUtils.ts | 74 ++++++++++ src/contacts/vcard/shared/vcard.d.ts | 1 + src/file/file.ts | 23 ++- src/ui/sidebar/components/SidebarRootView.tsx | 37 +++-- tests/file.spec.ts | 19 +++ tests/fixtures/organization.vcf | 8 ++ tests/fixtures/organizationNoFN.vcf | 7 + tests/nameUtils.spec.ts | 135 ++++++++++++++++++ tests/setup/emptyObsidianMock.ts | 12 +- tests/vcard.spec.ts | 99 ++++++++++--- 15 files changed, 417 insertions(+), 77 deletions(-) create mode 100644 src/contacts/vcard/shared/nameUtils.ts create mode 100644 tests/file.spec.ts create mode 100644 tests/fixtures/organization.vcf create mode 100644 tests/fixtures/organizationNoFN.vcf create mode 100644 tests/nameUtils.spec.ts diff --git a/.gitignore b/.gitignore index 8583130..b1c012a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ data.json # Exclude macOS Finder (System Explorer) View States .DS_Store + +# Claude +.claude diff --git a/assets/docs/testing-strategy.md b/assets/docs/testing-strategy.md index f9e734b..08f1acd 100644 --- a/assets/docs/testing-strategy.md +++ b/assets/docs/testing-strategy.md @@ -47,7 +47,7 @@ npm run test:coverage # Run tests with coverage report Contains the `*.spec.ts` test files. - `./tests/fixtures/` - Contains sample data,fixtures used by tests. These simulate realistic and/or defective inputs + Contains sample data (fixtures) used by tests. These simulate realistic and/or defective inputs --- diff --git a/src/contacts/contactMdTemplate.ts b/src/contacts/contactMdTemplate.ts index 73189c6..f853e3b 100644 --- a/src/contacts/contactMdTemplate.ts +++ b/src/contacts/contactMdTemplate.ts @@ -78,12 +78,16 @@ export function mdRender(record: Record, hashtags: string): string additionalTags = `#${tempTags.join(' #')}` } + // Combine all groups into a single object for YAML serialization + const frontmatter = { + ...sortNameItems(groups.name), + ...sortedPriorityItems(groups.priority), + ...groups.address, + ...groups.other + }; + return `--- -${stringifyYaml(sortNameItems(groups.name))} -${stringifyYaml(sortedPriorityItems(groups.priority))} -${stringifyYaml(groups.address)} -${stringifyYaml(groups.other)} ---- +${stringifyYaml(frontmatter)}--- #### Notes ${myNote} diff --git a/src/contacts/vcard/parse.ts b/src/contacts/vcard/parse.ts index 9529d3e..b52ea1d 100644 --- a/src/contacts/vcard/parse.ts +++ b/src/contacts/vcard/parse.ts @@ -1,7 +1,7 @@ import { VCardForObsidianRecord, VCardSupportedKey } from "src/contacts/vcard"; -import { ensureHasName } from "src/contacts/vcard/shared/ensureHasName"; import { StructuredFields } from "src/contacts/vcard/shared/structuredFields"; import { photoLineFromV3toV4 } from "src/util/photoLineFromV3toV4"; +import { createNameSlug } from "src/contacts/vcard/shared/nameUtils"; function unfoldVCardLines(vCardData: string): string[] { // Normalize line endings to \n first (handles \r, \r\n) @@ -151,16 +151,20 @@ function formatVCardDate(input: string): string { return trimmed; } -export function parseToSingles(vCardsRaw:string): string[] { - return vCardsRaw.split(/BEGIN:VCARD\s*[\n\r]+|END:VCARD\s*[\n\r]+/).filter(section => section.trim()); +function parseToSingles(vCardsRaw:string): string[] { + return vCardsRaw.split(/BEGIN:VCARD\s*[\n\r]+|END:VCARD\s*[\n\r]+/).filter(section => section.trim()); } -export async function parse(vCardData: string): Promise { +/** + * Parse vCard file and yields [slug, vCardObject] tuples. + * `slug` may be undefined for vcards without valid name info. + */ +export async function* parse(vCardData: string): AsyncGenerator<[string | undefined, VCardForObsidianRecord], void, unknown> { const singles: string[] = parseToSingles(vCardData); - - return await Promise.all(singles.map(async (singleVCard) => { + + for (const singleVCard of singles) { const unfoldedLines = unfoldVCardLines(singleVCard); - const vCardObject: Record = {}; + const vCardObject: VCardForObsidianRecord = {}; for (const line of unfoldedLines) { const parsedLine = parseVCardLine(line); @@ -169,9 +173,13 @@ export async function parse(vCardData: string): Promise { +export async function ensureHasName( + vCardObject: VCardForObsidianRecord +): Promise { + // If we can create a name slug from any available data (N fields, FN, or ORG), we're good + if (createNameSlug(vCardObject)) return Promise.resolve(vCardObject); + + // Need to prompt for individual's name const app = getApp(); return new Promise((resolve) => { - if (vCardObject['N.GN'] && vCardObject['N.FN']) { + console.warn("No name found for record", vCardObject); + new ContactNameModal(app, vCardObject["FN"], (givenName, familyName) => { + vCardObject["N.PREFIX"] ??= ""; + vCardObject["N.GN"] = givenName; + vCardObject["N.MN"] ??= ""; + vCardObject["N.FN"] = familyName; + vCardObject["N.SUFFIX"] ??= ""; resolve(vCardObject); - } else { - new ContactNameModal(app, vCardObject['FN'], (givenName, familyName) => { - if (vCardObject['N.PREFIX'] === undefined) { - vCardObject['N.PREFIX'] = ''; - } - vCardObject['N.GN'] = givenName; - if (vCardObject['N.MN'] === undefined) { - vCardObject['N.MN'] = ''; - } - vCardObject['N.FN'] = familyName; - if (vCardObject['N.SUFFIX'] === undefined) { - vCardObject['N.SUFFIX'] = ''; - } - resolve(vCardObject); - }).open(); - } + }).open(); }); } diff --git a/src/contacts/vcard/shared/nameUtils.ts b/src/contacts/vcard/shared/nameUtils.ts new file mode 100644 index 0000000..c3660f7 --- /dev/null +++ b/src/contacts/vcard/shared/nameUtils.ts @@ -0,0 +1,74 @@ +import { VCardForObsidianRecord } from "src/contacts/vcard/index"; + +/** + * Creates a name slug from vCard records. + * Builds slug from N components, with FN, NICKNAME, ORG, and UUID as fallbacks. + * The result is sanitized for use as a filename. + * @returns Sanitized name string or null if no name can be determined + */ +export function createNameSlug( + records: VCardForObsidianRecord +): string | undefined { + let n = + [ + records["N.PREFIX"], + records["N.GN"], + records["N.MN"], + records["N.FN"], + records["N.SUFFIX"], + ] + .map((part) => part?.trim()) + .filter((part) => part) + .join(" ") || undefined; + + n ??= + records["FN"]?.trim() || + records["NICKNAME"]?.trim() || + records["ORG"]?.trim() || + records["UUID"]?.trim() || + undefined; + + // Sanitize for filesystem: + // Replace characters that are problematic in filenames + // Keep spaces, letters, numbers, and common punctuation including dots + return n + ?.replace(/[<>:"\\|?*\x00-\x1F]/g, "") // Remove invalid filename chars + .replace(/\/+/g, " ") // Replace forward slashes with spaces + .replace(/\s+/g, " ") // Normalize multiple spaces to single space + .trim() + .replace(/^\.+/, "") // Remove leading dots + .replace(/\.{2,}/g, "."); // Replace multiple consecutive dots with single dot +} + +/** + * Checks if vCard has valid N (Name) fields. + * Valid means at least given name (GN) or family name (FN) is present. + */ +export function hasValidNFields(records: VCardForObsidianRecord): boolean { + // Check if at least one N field has a non-empty value + return !!( + (records["N.GN"] && records["N.GN"].trim()) || + (records["N.FN"] && records["N.FN"].trim()) + ); +} + +/** + * Determines if a vCard represents an organization rather than an individual. + * Uses both explicit KIND field and implicit detection (missing N fields). + * This matches behavior of macOS Contacts.app and other clients. + */ +export function isOrganization(records: VCardForObsidianRecord): boolean { + // Explicit: KIND field set to 'org' + const kind = records["KIND"]?.toLowerCase(); + if (kind === "org" || kind === "organization") return true; + + // If explicitly set to individual, respect that + if (kind === "individual") return false; + + // Implicit: No valid N fields (like macOS Contacts.app) + // If there's no N data but there is ORG data, treat as organization + if (!hasValidNFields(records) && records["ORG"]) return true; + + // Default to individual + return false; +} diff --git a/src/contacts/vcard/shared/vcard.d.ts b/src/contacts/vcard/shared/vcard.d.ts index 6d4d213..3a48e36 100644 --- a/src/contacts/vcard/shared/vcard.d.ts +++ b/src/contacts/vcard/shared/vcard.d.ts @@ -13,6 +13,7 @@ export enum VCardSupportedKey { EMAIL = "Email Address", GENDER = "Gender", GEO = "Geolocation (Latitude/Longitude)", + KIND = "Contact Type", LANG = "Language Spoken", MEMBER = "Group Member", NAME = "Name Identifier", diff --git a/src/file/file.ts b/src/file/file.ts index afc5aea..cb63862 100644 --- a/src/file/file.ts +++ b/src/file/file.ts @@ -1,9 +1,10 @@ -import {App, normalizePath, Notice, Platform,TAbstractFile, TFile, TFolder, Vault, Workspace} from "obsidian"; +import {App, normalizePath, Notice, Platform, TFile, TFolder, Vault, Workspace} from "obsidian"; import { getFrontmatterFromFiles } from "src/contacts"; import { getSettings } from "src/context/sharedSettingsContext"; import { RunType } from "src/insights/insight.d"; import { insightService } from "src/insights/insightService"; import { FileExistsModal } from "src/ui/modals/fileExistsModal"; +import { createNameSlug } from "src/contacts/vcard/shared/nameUtils"; export async function openFile(file: TFile, workspace: Workspace) { const leaf = workspace.getLeaf() @@ -180,18 +181,14 @@ export function saveVcardFilePicker(data: string, obsidianFile?: TFile ) { } export function createFileName(records: Record) { - const parts = [ - records['N.PREFIX'] || '', - records['N.GN'] || '', - records['N.MN'] || '', - records['N.FN'] || '', - records['N.SUFFIX'] || '' - ]; - - return parts - .map(part => part.trim()) - .filter(part => part !== '') - .join(' ') + '.md'; + const nameSlug = createNameSlug(records); + + if (!nameSlug) { + console.error('No name found for record', records); + throw new Error('No name found for record'); + } + + return nameSlug + '.md'; } diff --git a/src/ui/sidebar/components/SidebarRootView.tsx b/src/ui/sidebar/components/SidebarRootView.tsx index 9079562..2739fa0 100644 --- a/src/ui/sidebar/components/SidebarRootView.tsx +++ b/src/ui/sidebar/components/SidebarRootView.tsx @@ -1,9 +1,10 @@ -import { MarkdownView, normalizePath, Notice, TFile, TFolder } from "obsidian"; +import { App, MarkdownView, normalizePath, Notice, TFile, TFolder } from "obsidian"; import * as React from "react"; import { Contact, getFrontmatterFromFiles, mdRender } from "src/contacts"; import { vcard } from "src/contacts/vcard"; import { getApp } from "src/context/sharedAppContext"; import { getSettings, onSettingsChange } from "src/context/sharedSettingsContext"; +import { ContactsPluginSettings } from "src/settings/settings.d"; import { createContactFile, createFileName, @@ -22,6 +23,30 @@ interface SidebarRootViewProps { createDefaultPluginFolder: () => Promise; } +const importVCFContacts = async (fileContent: string, app: App, settings: ContactsPluginSettings) => { + if (fileContent === '') return; + + let imported = 0; + let skipped = 0; + + // Use generator to avoid double parsing and reduce memory usage + for await (const [slug, record] of vcard.parse(fileContent)) { + if (slug) { + const mdContent = mdRender(record, settings.defaultHashtag); + const filename = slug + '.md'; + createContactFile(app, settings.contactsFolder, mdContent, filename); + imported++; + } else { + // Contact has no valid name/slug + console.warn("Skipping contact without name", record); + skipped++; + } + } + + if (skipped > 0) new Notice(`Skipped ${skipped} contact(s) without name information`); + if (imported > 0) new Notice(`Imported ${imported} contact(s)`); +}; + export const SidebarRootView = (props: SidebarRootViewProps) => { const app = getApp(); const { vault, workspace } = app; @@ -109,15 +134,7 @@ export const SidebarRootView = (props: SidebarRootViewProps) => { onSortChange={setSort} importVCF={() => { openFilePicker('.vcf').then(async (fileContent: string) => { - if (fileContent === '') { - return; - } else { - const records = await vcard.parse(fileContent); - for (const record of records) { - const mdContent = mdRender(record, settings.defaultHashtag); - createContactFile(app, settings.contactsFolder, mdContent, createFileName(record)) - } - } + await importVCFContacts(fileContent, app, settings); }) }} exportAllVCF={async() => { diff --git a/tests/file.spec.ts b/tests/file.spec.ts new file mode 100644 index 0000000..a89be73 --- /dev/null +++ b/tests/file.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { createFileName } from 'src/file/file'; +import { createNameSlug } from 'src/contacts/vcard/shared/nameUtils'; + +describe('createFileName', () => { + it('should add .md extension to name slug', () => { + const records = { + 'N.GN': 'Jane', + 'N.FN': 'Doe' + }; + const slug = createNameSlug(records); + expect(createFileName(records)).toBe(slug + '.md'); + }); + + it('should throw error when no name data is available', () => { + const records = {}; + expect(() => createFileName(records)).toThrow('No name found for record'); + }); +}); \ No newline at end of file diff --git a/tests/fixtures/organization.vcf b/tests/fixtures/organization.vcf new file mode 100644 index 0000000..9e5732d --- /dev/null +++ b/tests/fixtures/organization.vcf @@ -0,0 +1,8 @@ +BEGIN:VCARD +VERSION:4.0 +KIND:org +FN:Acme Corporation +ORG:Acme Corporation +TEL;TYPE=WORK:+1-555-0100 +EMAIL;TYPE=WORK:contact@acme.corp +END:VCARD \ No newline at end of file diff --git a/tests/fixtures/organizationNoFN.vcf b/tests/fixtures/organizationNoFN.vcf new file mode 100644 index 0000000..04d5032 --- /dev/null +++ b/tests/fixtures/organizationNoFN.vcf @@ -0,0 +1,7 @@ +BEGIN:VCARD +VERSION:4.0 +KIND:org +ORG:Tech Solutions Inc +TEL;TYPE=WORK:+1-555-0200 +EMAIL;TYPE=WORK:info@techsolutions.com +END:VCARD \ No newline at end of file diff --git a/tests/nameUtils.spec.ts b/tests/nameUtils.spec.ts new file mode 100644 index 0000000..78a3641 --- /dev/null +++ b/tests/nameUtils.spec.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import { createNameSlug, hasValidNFields, isOrganization } from 'src/contacts/vcard/shared/nameUtils'; + +describe('nameUtils', () => { + describe('createNameSlug', () => { + it('should create slug from N components', () => { + const records = { + 'N.PREFIX': 'Dr.', + 'N.GN': 'John', + 'N.MN': 'Q', + 'N.FN': 'Smith', + 'N.SUFFIX': 'Jr.' + }; + expect(createNameSlug(records)).toBe('Dr. John Q Smith Jr.'); + }); + + it('should handle partial N components', () => { + const records = { + 'N.GN': 'Jane', + 'N.FN': 'Doe' + }; + expect(createNameSlug(records)).toBe('Jane Doe'); + }); + + it('should fallback to FN when N components are empty', () => { + const records = { + 'N.PREFIX': '', + 'N.GN': '', + 'N.FN': '', + 'FN': 'Acme Corporation' + }; + expect(createNameSlug(records)).toBe('Acme Corporation'); + }); + + it('should fallback to ORG when N and FN are empty', () => { + const records = { + 'ORG': 'Tech Solutions Inc' + }; + expect(createNameSlug(records)).toBe('Tech Solutions Inc'); + }); + + it('should return undefined when no name data exists', () => { + const records = {}; + expect(createNameSlug(records)).toBeUndefined(); + }); + + it('should sanitize problematic filename characters', () => { + const records = { + 'FN': 'John/Doe: ' + }; + expect(createNameSlug(records)).toBe('John Doe CEO'); + }); + + it('should handle multiple consecutive dots', () => { + const records = { + 'FN': 'Company Inc...' + }; + expect(createNameSlug(records)).toBe('Company Inc.'); + }); + + it('should handle names with pipe and asterisk', () => { + const records = { + 'N.GN': 'John*', + 'N.FN': 'Doe|Smith' + }; + expect(createNameSlug(records)).toBe('John DoeSmith'); + }); + }); + + describe('hasValidNFields', () => { + it('should return true when both GN and FN exist', () => { + expect(hasValidNFields({ 'N.GN': 'John', 'N.FN': 'Doe' })).toBe(true); + }); + + it('should return true when only GN exists', () => { + expect(hasValidNFields({ 'N.GN': 'John' })).toBe(true); + }); + + it('should return true when only FN exists', () => { + expect(hasValidNFields({ 'N.FN': 'Doe' })).toBe(true); + }); + + it('should return false when N fields are empty', () => { + expect(hasValidNFields({ 'N.GN': '', 'N.FN': '' })).toBe(false); + }); + + it('should return false when N fields are missing', () => { + expect(hasValidNFields({})).toBe(false); + }); + }); + + describe('isOrganization', () => { + it('should detect explicit KIND:org', () => { + expect(isOrganization({ 'KIND': 'org' })).toBe(true); + expect(isOrganization({ 'KIND': 'ORG' })).toBe(true); + expect(isOrganization({ 'KIND': 'organization' })).toBe(true); + }); + + it('should respect explicit KIND:individual', () => { + expect(isOrganization({ 'KIND': 'individual', 'ORG': 'Company' })).toBe(false); + }); + + it('should detect implicit organization (no N fields + has ORG)', () => { + const records = { + 'ORG': 'Acme Corporation', + 'FN': 'Acme Corporation' + }; + expect(isOrganization(records)).toBe(true); + }); + + it('should not detect organization when N fields exist', () => { + const records = { + 'N.GN': 'John', + 'N.FN': 'Doe', + 'ORG': 'Acme Corporation' + }; + expect(isOrganization(records)).toBe(false); + }); + + it('should default to individual when no clear indicators', () => { + expect(isOrganization({})).toBe(false); + expect(isOrganization({ 'FN': 'Some Name' })).toBe(false); + }); + + it('should handle macOS-style organization detection', () => { + // macOS treats contacts without N fields as organizations + const macOSOrg = { + 'FN': 'Apple Inc.', + 'ORG': 'Apple Inc.', + 'TEL': '+1-408-996-1010' + }; + expect(isOrganization(macOSOrg)).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/tests/setup/emptyObsidianMock.ts b/tests/setup/emptyObsidianMock.ts index 97d872f..5deb617 100644 --- a/tests/setup/emptyObsidianMock.ts +++ b/tests/setup/emptyObsidianMock.ts @@ -1,2 +1,10 @@ -import { vi } from 'vitest'; -vi.mock('obsidian', () => ({})); +// Minimal mocks for Obsidian API - only what's needed for tests +export const App = {}; +export class Modal {} // Needs to be a class since FileExistsModal extends it +export const Notice = {}; +export const TFile = {}; +export const TFolder = {}; +export const Vault = { recurseChildren: () => {} }; +export const Workspace = {}; +export const normalizePath = (path: string) => path; +export const Platform = { isMobileApp: false, isAndroidApp: false }; diff --git a/tests/vcard.spec.ts b/tests/vcard.spec.ts index ae0956f..1597f3c 100644 --- a/tests/vcard.spec.ts +++ b/tests/vcard.spec.ts @@ -1,9 +1,17 @@ import { App, TFile } from "obsidian"; -import { vcard } from "src/contacts/vcard"; +import { vcard, VCardForObsidianRecord } from "src/contacts/vcard"; import { setApp } from "src/context/sharedAppContext"; import { fixtures } from "tests/fixtures/fixtures"; import { describe, expect, it, vi } from 'vitest'; +// Helper function to parse vCards and collect only those with valid slugs +const parseValidVCards = async (vcfData: string) => { + const cards: VCardForObsidianRecord[] = []; + for await (const [slug, card] of vcard.parse(vcfData)) + if (slug) cards.push(card); + return cards; +}; + setApp({ metadataCache: { getFileCache: (file: TFile) => { @@ -51,21 +59,21 @@ describe('vcard creatEmpty', () => { describe('vcard parse', () => { - it('Should first name and lastname are filled', async () => { + it('Should parse N field components correctly', async () => { const vcf = fixtures.readVcfFixture('noFirstName.vcf'); - const result = await vcard.parse(vcf); - const expectedFields = ['N.PREFIX', 'N.GN', 'N.MN', 'N.FN', 'N.SUFFIX']; - expectedFields.forEach((field) => { - expect(result[0]).toHaveProperty(field); - }); - expect(result[0]['N.GN']).toBe('Foo'); - expect(result[0]['N.FN']).toBe('Bar'); - + const result = await parseValidVCards(vcf); + // N field is ";Zahra;;;" so only N.GN should be present + expect(result[0]['N.GN']).toBe('Zahra'); + // Empty components should not create fields + expect(result[0]['N.FN']).toBeUndefined(); + expect(result[0]['N.PREFIX']).toBeUndefined(); + expect(result[0]['N.MN']).toBeUndefined(); + expect(result[0]['N.SUFFIX']).toBeUndefined(); }); it('should only import variables that are in a predifined list ', async () => { const vcf = fixtures.readVcfFixture('hasNonSpecParameters.vcf'); - const result = await vcard.parse(vcf); + const result = await parseValidVCards(vcf); expect(result[0]['N.GN']).toBe('Name'); expect(result[0]['N.FN']).toBe('My'); expect(result[0]['NONSPEC']).toBe(undefined); @@ -74,7 +82,7 @@ describe('vcard parse', () => { it('should convert some (photo, version) v3 parameters to a v4 implementation', async () => { const vcf = fixtures.readVcfFixture('v3SpecificParameters.vcf'); - const result = await vcard.parse(vcf); + const result = await parseValidVCards(vcf); expect(result[0]['N.GN']).toBe('Huntelaar'); expect(result[0]['N.FN']).toBe('Jan'); expect(result[0]['PHOTO']).toEqual('data:image/type=jpeg;base64,/9j/4AAQSkZJRgABAQEAAAAAAAD/2wCEAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ'); @@ -83,15 +91,18 @@ describe('vcard parse', () => { it('should be able to parse multiple cards from one file', async () => { const vcf = fixtures.readVcfFixture('hasMultipleCards.vcf'); - const result = await vcard.parse(vcf); - expect(result.length).toBe(3); - expect(result[0]['N.GN']).toBe('Foo'); - expect(result[0]['N.FN']).toBe('Bar'); + const result = await parseValidVCards(vcf); + // First card has no N field, so it's skipped - only 2 cards imported + expect(result.length).toBe(2); + expect(result[0]['N.GN']).toBe('Zinkie'); + expect(result[0]['N.FN']).toBe('Namiton'); + expect(result[1]['N.GN']).toBe('Lansdorf'); + expect(result[1]['N.FN']).toBe('Mindie'); }); it('should add indexes to duplicate field names tobe spec compatible', async () => { const vcf = fixtures.readVcfFixture('hasDuplicateParameters.vcf'); - const result = await vcard.parse(vcf); + const result = await parseValidVCards(vcf); const expectedKeys = [ "TEL[WORK]", "TEL[1:WORK]", @@ -126,7 +137,7 @@ describe('vcard parse', () => { it('should preform try to unify dates received ', async () => { const vcf = fixtures.readVcfFixture('hasDifferentDates.vcf'); - const result = await vcard.parse(vcf); + const result = await parseValidVCards(vcf); const isIsoDate = (v: string) => /^\d{4}-\d{2}-\d{2}$/.test(v); expect(isIsoDate(result[0]['BDAY'])).toBe(true); @@ -138,11 +149,61 @@ describe('vcard parse', () => { it('should be able to handle a spec folded multiline.', async () => { const vcf = fixtures.readVcfFixture('hasFoldeLines.vcf'); - const result = await vcard.parse(vcf); + const result = await parseValidVCards(vcf); expect(result[0]['N.GN']).toBe('Huntelaar'); expect(result[0]['N.FN']).toBe('Jan'); expect(result[0]['NOTE']).toBe('This is a long note that continues on the next line, and still keeps going.'); }); + + it('should handle organization contacts without name dialog', async () => { + const vcf = fixtures.readVcfFixture('organization.vcf'); + const result = await parseValidVCards(vcf); + expect(result[0]['KIND']).toBe('org'); + expect(result[0]['FN']).toBe('Acme Corporation'); + expect(result[0]['ORG']).toBe('Acme Corporation'); + // Should not have N fields populated since it's an organization + expect(result[0]['N.GN']).toBeUndefined(); + expect(result[0]['N.FN']).toBeUndefined(); + }); + + it('should NOT use ORG as FN fallback for organizations without FN', async () => { + const vcf = fixtures.readVcfFixture('organizationNoFN.vcf'); + const result = await parseValidVCards(vcf); + expect(result[0]['KIND']).toBe('org'); + expect(result[0]['FN']).toBeUndefined(); // FN should not be populated from ORG + expect(result[0]['ORG']).toBe('Tech Solutions Inc'); + // Should not have N fields populated since it's an organization + expect(result[0]['N.GN']).toBeUndefined(); + expect(result[0]['N.FN']).toBeUndefined(); + }); + + it('should skip organization contacts without FN or ORG', async () => { + const vcfWithoutFnOrOrg = `BEGIN:VCARD +VERSION:4.0 +KIND:org +TEL;TYPE=WORK:+1-555-0300 +END:VCARD`; + + // Organization without FN or ORG has no valid slug, so it's skipped + const result = await parseValidVCards(vcfWithoutFnOrOrg); + expect(result.length).toBe(0); + }); + + it('should detect implicit organization (no N fields)', async () => { + const vcfImplicitOrg = `BEGIN:VCARD +VERSION:4.0 +FN:Tech Company +ORG:Tech Company +TEL;TYPE=WORK:+1-555-0400 +END:VCARD`; + + const result = await parseValidVCards(vcfImplicitOrg); + // Should not trigger name dialog since it's detected as org + expect(result[0]['FN']).toBe('Tech Company'); + expect(result[0]['ORG']).toBe('Tech Company'); + expect(result[0]['N.GN']).toBeUndefined(); + expect(result[0]['N.FN']).toBeUndefined(); + }); });