mirror of
https://github.com/broekema41/obsidian-vcf-contacts.git
synced 2026-07-22 05:42:58 +00:00
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:
parent
529adf16f4
commit
906818901a
15 changed files with 417 additions and 77 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,3 +21,6 @@ data.json
|
|||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
# Claude
|
||||
.claude
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
74
src/contacts/vcard/shared/nameUtils.ts
Normal file
74
src/contacts/vcard/shared/nameUtils.ts
Normal 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;
|
||||
}
|
||||
1
src/contacts/vcard/shared/vcard.d.ts
vendored
1
src/contacts/vcard/shared/vcard.d.ts
vendored
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
19
tests/file.spec.ts
Normal 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
8
tests/fixtures/organization.vcf
vendored
Normal 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
7
tests/fixtures/organizationNoFN.vcf
vendored
Normal 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
135
tests/nameUtils.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue