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.
This commit is contained in:
Bjørn Stabell 2025-08-29 16:45:27 -07:00
parent 529adf16f4
commit 906818901a
15 changed files with 417 additions and 77 deletions

3
.gitignore vendored
View file

@ -21,3 +21,6 @@ data.json
# Exclude macOS Finder (System Explorer) View States
.DS_Store
# Claude
.claude

View file

@ -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
---

View file

@ -78,12 +78,16 @@ export function mdRender(record: Record<string, any>, 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}

View file

@ -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<VCardForObsidianRecord[]> {
/**
* 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<string, any> = {};
const vCardObject: VCardForObsidianRecord = {};
for (const line of unfoldedLines) {
const parsedLine = parseVCardLine(line);
@ -169,9 +173,13 @@ export async function parse(vCardData: string): Promise<VCardForObsidianRecord[]
Object.assign(vCardObject, indexedParsedLine);
}
}
return await ensureHasName(vCardObject);
}));
// Check if contact has any name information
const slug = createNameSlug(vCardObject);
yield [slug, vCardObject];
}
}

View file

@ -1,27 +1,25 @@
import { VCardForObsidianRecord } from "src/contacts/vcard/index";
import { getApp } from "src/context/sharedAppContext";
import { ContactNameModal } from "src/ui/modals/contactNameModal";
import { createNameSlug } from "src/contacts/vcard/shared/nameUtils";
export async function ensureHasName(vCardObject: VCardForObsidianRecord): Promise<VCardForObsidianRecord> {
export async function ensureHasName(
vCardObject: VCardForObsidianRecord
): Promise<VCardForObsidianRecord> {
// 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();
});
}

View file

@ -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;
}

View file

@ -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",

View file

@ -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<string, string>) {
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';
}

View file

@ -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<void>;
}
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() => {

19
tests/file.spec.ts Normal file
View file

@ -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');
});
});

8
tests/fixtures/organization.vcf vendored Normal file
View file

@ -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

7
tests/fixtures/organizationNoFN.vcf vendored Normal file
View file

@ -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

135
tests/nameUtils.spec.ts Normal file
View file

@ -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: <CEO>'
};
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);
});
});
});

View file

@ -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 };

View file

@ -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();
});
});