Refs #8: mergin in master so that we can contignue dev on this feature

This commit is contained in:
Roland Broekema 2025-09-19 16:43:39 +02:00
commit 200e6c0d62
37 changed files with 835 additions and 215 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

@ -337,6 +337,23 @@ Start using the plugin today and share your experience in the [💬 GitHub Discu
---
## 🔧 Development
For plugin development:
```bash
# Install dependencies
npm install
# Build the plugin
npm run build
# Development build
npm run dev
npm run dev watch # with auto-rebuild on changes
```
---
## 📘 Testing strategy
Our goal is to maintain high-confidence, non-UI testing that focuses on:
* Validating all resolved production bugs.

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,18 @@
---
FN: kite surf spot "zandmotor"
ORG: ""
PHOTO: https://raw.githubusercontent.com/broekema41/obsidian-vcf-contacts/refs/heads/master/assets/demo-data/avatars/avatar19.jpg
ADR.STREET: Strandslag 1
ADR.LOCALITY: Den Haag
ADR.POSTAL: 3653 GP
ADR.COUNTRY: The Netherlands
CATEGORIES: Surfing
VERSION: "4.0"
KIND: location
UID: urn:uuid:019961ad693e4-4f04-8358-de29da2ef596
---
#### Notes

File diff suppressed because one or more lines are too long

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

@ -10,35 +10,41 @@ if you want to view the source, please visit the github repository of this plugi
const prod = process.argv[2] === "production";
esbuild
.build({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
format: "cjs",
target: "es2022",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
minify: true,
outfile: "main.js",
})
.catch(() => process.exit(1));
const ctx = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["src/main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
format: "cjs",
target: "es2022",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
minify: true,
outfile: "main.js",
});
if (process.argv[2] === "watch") {
await ctx.watch();
console.log("⚡ Watch mode enabled - rebuilding on file changes...");
} else {
await ctx.rebuild();
await ctx.dispose();
}

View file

@ -1,7 +1,7 @@
{
"id": "vcf-contacts",
"name": "VCF Contacts",
"version": "2.1.0",
"version": "2.2.0",
"minAppVersion": "1.4.10",
"description": "Effortlessly manage, organize, and interact with your contacts. Import, export, and structure vCard (VCF) files seamlessly while keeping all contact details accessible in your knowledge base. Includes click-to-call, right-click copy, structured metadata, and more!",
"author": "Roland Broekema",

View file

@ -1,6 +1,6 @@
{
"name": "vcf-contacts",
"version": "2.1.0",
"version": "2.2.0",
"description": "Effortlessly manage, organize, and interact with your contacts inside Obsidian. Import, export, and structure vCard (VCF) files seamlessly while keeping all contact details accessible in your knowledge base. Includes click-to-call, right-click copy, structured metadata, and more!",
"main": "main.js",
"scripts": {

View file

@ -11,7 +11,7 @@ export async function getFrontmatterFromFiles(files: TFile[]) {
const contactsData: Contact[] = [];
for (const file of files) {
const frontMatter = metadataCache.getFileCache(file)?.frontmatter
if (frontMatter?.['N.GN'] && frontMatter?.['N.FN'] ) {
if ((frontMatter?.['N.GN'] && frontMatter?.['N.FN']) || frontMatter?.['FN']) {
contactsData.push({
file,
data: frontMatter,

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,6 +1,6 @@
import { VCardForObsidianRecord, VCardSupportedKey } from "src/contacts/vcard";
import { ensureHasName } from "src/contacts/vcard/shared/ensureHasName";
import { StructuredFields } from "src/contacts/vcard/shared/structuredFields";
import { createNameSlug } from "src/util/nameUtils";
import { photoLineFromV3toV4 } from "src/util/photoLineFromV3toV4";
function unfoldVCardLines(vCardData: string): string[] {
@ -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,20 @@ export async function parse(vCardData: string): Promise<VCardForObsidianRecord[]
Object.assign(vCardObject, indexedParsedLine);
}
}
return await ensureHasName(vCardObject);
}));
/**
* TODO: ok, hmm. now we are skipping if we can not generate a name.
* the generator pattern can make it difficult to prompt the user.
*/
try {
const slug = createNameSlug(vCardObject);
yield [slug, vCardObject];
} catch (error) {
yield [undefined, vCardObject];
}
}
}

View file

@ -1,27 +1,34 @@
import { VCardForObsidianRecord } from "src/contacts/vcard/index";
import { VCardKinds } from "src/contacts/vcard/shared/structuredFields";
import { getApp } from "src/context/sharedAppContext";
import { ContactNameModal } from "src/ui/modals/contactNameModal";
import { ContactNameModal, NamingPayload } from "src/ui/modals/contactNameModal";
import { createNameSlug } from "src/util/nameUtils";
export async function ensureHasName(vCardObject: VCardForObsidianRecord): Promise<VCardForObsidianRecord> {
const app = getApp();
return new Promise((resolve) => {
if (vCardObject['N.GN'] && vCardObject['N.FN']) {
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'] = '';
export async function ensureHasName(
vCardObject: VCardForObsidianRecord
): Promise<VCardForObsidianRecord> {
try {
// if we can create a file name then we meet the minimum requirements
createNameSlug(vCardObject)
return Promise.resolve(vCardObject);
} catch (error) {
// Need to prompt for some form of name information.
const app = getApp();
return new Promise((resolve) => {
console.warn("No name found for record", vCardObject);
new ContactNameModal(app, (nameData: NamingPayload) => {
if(nameData.kind === VCardKinds.Individual) {
vCardObject["N.PREFIX"] ??= "";
vCardObject["N.GN"] = nameData.given;
vCardObject["N.MN"] ??= "";
vCardObject["N.FN"] = nameData.family;
vCardObject["N.SUFFIX"] ??= "";
} else {
vCardObject["FN"] ??= nameData.fn;
}
vCardObject["KIND"] ??= nameData.kind;
resolve(vCardObject);
}).open();
}
});
});
}
}

View file

@ -2,3 +2,10 @@ export const StructuredFields = {
N: ["FN", "GN", "MN", "PREFIX", "SUFFIX"],
ADR: ["PO", "EXT", "STREET", "LOCALITY", "REGION", "POSTAL", "COUNTRY"]
} as const;
export const VCardKinds = {
Individual: "individual",
Organisation: "org",
Group: "group",
Location: "location",
} as const;

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",
@ -44,3 +45,5 @@ export interface VCardToStringReply {
vcards: string;
errors: VCardToStringError[];
}
export type VCardKind = "individual" | "org" | "group" | "location";

View file

@ -65,6 +65,11 @@ function generateVCard(file: TFile): string {
singleLineFields.push(['FN', file.basename]);
}
const hasFN = singleLineFields.some(([key, _]) => key === 'FN');
if (!hasFN) {
singleLineFields.push(['FN', file.basename]);
}
const structuredLines = renderStructuredLines(structuredFields);
const singleLines = singleLineFields.map(renderSingleKey);
const lines = structuredLines.concat(singleLines);

View file

@ -1,9 +1,11 @@
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/util/nameUtils";
export async function openFile(file: TFile, workspace: Workspace) {
const leaf = workspace.getLeaf()
@ -180,18 +182,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'] || ''
];
const nameSlug = createNameSlug(records);
return parts
.map(part => part.trim())
.filter(part => part !== '')
.join(' ') + '.md';
if (!nameSlug) {
console.error('No name found for record', records);
throw new Error('No name found for record');
}
return nameSlug + '.md';
}

View file

@ -26,6 +26,25 @@ export default class ContactsPlugin extends Plugin {
});
this.addSettingTab(new ContactsSettingTab(this.app, this));
this.addCommand({
id: 'contacts-sidebar',
name: "Open Contacts Sidebar",
callback: () => {
this.activateSidebarView();
},
});
this.addCommand({
id: 'contacts-create',
name: "Create Contact",
callback: async () => {
const leaf = await this.activateSidebarView();
leaf?.createNewContact()
},
});
}
onunload() {}
@ -62,8 +81,11 @@ export default class ContactsPlugin extends Plugin {
}
}
await this.app.workspace.revealLeaf(
this.app.workspace.getLeavesOfType(CONTACTS_VIEW_CONFIG.type)[0]
);
// Grab the leaf
const leaf = this.app.workspace.getLeavesOfType(CONTACTS_VIEW_CONFIG.type)[0];
if (!leaf) return null;
await this.app.workspace.revealLeaf(leaf);
return leaf.view as ContactsView;
}
}

View file

@ -1,88 +1,158 @@
import { App, Modal, Notice } from "obsidian";
import { useState } from "react";
import * as React from "react";
import { createRoot, Root } from "react-dom/client"
import { VCardKind } from "src/contacts/vcard";
import { VCardKinds } from "src/contacts/vcard/shared/structuredFields";
type IndividualPayload = {
kind: typeof VCardKinds.Individual;
given: string;
family: string;
};
type NonIndividualPayload = {
kind:
| typeof VCardKinds.Organisation
| typeof VCardKinds.Group
| typeof VCardKinds.Location;
fn: string;
};
export type NamingPayload = IndividualPayload | NonIndividualPayload;
interface ContactNameModalProps {
vcfFn?: string;
onClose: () => void;
onSubmit: (givenName: string, familyName: string) => void;
onSubmit: (nameData: NamingPayload) => void;
}
const ContactNameModalContent: React.FC<ContactNameModalProps> = ({ vcfFn, onClose, onSubmit }) => {
const givenRef = React.useRef<HTMLInputElement>(null);
const ContactNameModalContent: React.FC<ContactNameModalProps> = ({ onClose, onSubmit }) => {
const [kind, setKind] = useState<"individual" | "org" | "location" | "group">("individual");
const givenRef = React.useRef<HTMLInputElement>(null);
const familyRef = React.useRef<HTMLInputElement>(null);
const fnRef = React.useRef<HTMLInputElement>(null);
const handleSubmit = () => {
const given = givenRef.current?.value.trim() ?? "";
const family = familyRef.current?.value.trim() ?? "";
const fn = fnRef.current?.value.trim() ?? "";
if (kind === VCardKinds.Individual) {
if(!given || !family) {
new Notice("Please enter basic name information.");
return;
}
onSubmit({
kind: VCardKinds.Individual,
given,
family,
});
} else {
if(!fn) {
new Notice("Please enter basic name information.");
return;
}
onSubmit({
kind,
fn
});
}
if (!given || !family) {
new Notice("Please enter both a given name and a family name.");
return;
}
onSubmit(given, family);
onClose();
};
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
setKind(e.target.value as VCardKind);
};
React.useEffect(() => {
givenRef.current?.focus();
}, []);
return (
<div className="contact-modal">
<div className="contact-modal-field">
<label className="contact-modal-label">
Given Name:
</label>
<input
ref={givenRef}
type="text"
className="contact-modal-input"
/>
</div>
<div>
<label className="contact-modal-label">
Family Name:
</label>
<input
ref={familyRef}
type="text"
className="contact-modal-input"
/>
</div>
<div className="contact-modal-buttons">
<button className="mod-cta"
onClick={handleSubmit}
>
Submit
</button>
</div>
</div>
);
<div className="contact-modal">
<div className="contact-modal-field">
<label className="contact-modal-label">
Contact type:
</label>
<select
id="kind-select"
className="dropdown contact-modal-input"
value={kind}
onChange={handleChange}>
{Object.entries(VCardKinds).map(([label, value]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{kind === VCardKinds.Individual ? (
<>
<div className="contact-modal-field">
<label className="contact-modal-label">
Given name:
</label>
<input
ref={givenRef}
type="text"
className="contact-modal-input"
/>
</div>
<div className="contact-modal-field">
<label className="contact-modal-label">
Family name:
</label>
<input
ref={familyRef}
type="text"
className="contact-modal-input"
/>
</div>
</>
) : (
<div className="contact-modal-field">
<label className="contact-modal-label">
Functional name:
</label>
<input
ref={fnRef}
type="text"
className="contact-modal-input"
/>
</div>
)}
<div className="contact-modal-buttons">
<button className="mod-cta"
onClick={handleSubmit}
>
Submit
</button>
</div>
</div>
);
};
export class ContactNameModal extends Modal {
private reactRoot: Root | null = null;
private vcfFn: string | undefined;
private callback: (givenName: string, familyName: string) => void;
private reactRoot: Root | null = null;
private callback: (nameData: NamingPayload) => void;
constructor(app: App, vcfFn: string | undefined, callback: (givenName: string, familyName: string) => void) {
constructor(app: App, callback: (nameData: NamingPayload) => void) {
super(app);
this.vcfFn = vcfFn;
this.callback = callback;
}
onOpen() {
this.titleEl.setText(`Enter full name${this.vcfFn ? " for " + this.vcfFn : ""}`);
this.titleEl.setText(`Enter functional name`);
this.reactRoot = createRoot(this.contentEl);
this.reactRoot.render(
<ContactNameModalContent
vcfFn={this.vcfFn}
onClose={() => this.close()}
onSubmit={(given, family) => this.callback(given, family)}
onSubmit={(nameData: NamingPayload) => this.callback(nameData)}
/>
);
}

View file

@ -1,3 +1,4 @@
import { init } from "es-module-lexer";
import * as React from "react";
import { photoLineFromV3toV4 } from "src/util/photoLineFromV3toV4";
@ -6,11 +7,18 @@ interface AvatarProps {
photoUrl?: string;
firstName: string;
lastName: string;
functionalName: string;
}
export const Avatar = (props: AvatarProps) => {
const [hasImageError, setHasImageError] = React.useState(false);
const initials = `${props.firstName.charAt(0).toUpperCase()}${props.lastName.charAt(0).toUpperCase()}`;
let initials = ''
if (props.firstName && props.lastName) {
initials =`${props.firstName.charAt(0).toUpperCase()}${props.lastName.charAt(0).toUpperCase()}`;
} else if (props.functionalName) {
initials = `${props.functionalName.charAt(0).toUpperCase()}${props.functionalName.charAt(1).toUpperCase()}`;
}
React.useEffect(() => {
setHasImageError(false);

View file

@ -7,6 +7,7 @@ import { sync } from "src/sync";
import { useSyncEnabled } from "src/ui/hooks/syncEnabledHook";
import Avatar from "src/ui/sidebar/components/Avatar";
import { CopyableItem } from "src/ui/sidebar/components/CopyableItem";
import { getUiName, uiSafeString } from "src/util/nameUtils";
type ContactProps = {
contact: Contact;
@ -14,19 +15,7 @@ type ContactProps = {
processAvatar: (contact: Contact) => void;
};
const uiSafeString = (input: unknown): string | undefined => {
if (input === null || input === undefined){
return undefined;
}
if (typeof input === 'string') {
return input.trim();
} else if (typeof input === 'number' || input instanceof Date || typeof input === 'boolean') {
return input.toString();
} else {
return undefined;
}
}
export const ContactView = (props: ContactProps) => {
const syncEnabled = useSyncEnabled();
@ -186,19 +175,11 @@ export const ContactView = (props: ContactProps) => {
<div className="biz-card-a">
<div className="biz-headshot biz-pic-drew">
<Avatar photoUrl={contact.data["PHOTO"]} firstName={contact.data["N.GN"]}
lastName={contact.data["N.FN"]}/>
lastName={contact.data["N.FN"]} functionalName={contact.data["FN"]}/>
<div className="biz-words-container">
<div className="biz-name">
{[
contact.data["N.PREFIX"],
contact.data["N.GN"],
contact.data["N.MN"],
contact.data["N.FN"],
contact.data["N.SUFFIX"]
]
.map(uiSafeString)
.filter((value)=> value !== undefined)
.join(' ')}</div>
{getUiName(contact.data)}
</div>
{contact.data["ROLE"] ? (
<div className="biz-title">{contact.data["ROLE"]}</div>

View file

@ -5,6 +5,7 @@ import { fileId } from "src/file/file";
import { ContactView } from "src/ui/sidebar/components/ContactView";
import { Sort } from "src/util/constants";
import myScrollTo from "src/util/myScrollTo";
import { getSortName } from "src/util/nameUtils";
type ContactsListProps = {
@ -43,8 +44,11 @@ export const ContactsListView = (props: ContactsListProps) => {
React.useEffect(() => {
const sortedContacts = [...contacts].sort((a, b) => {
switch (sort) {
case Sort.NAME:
return (a.data['N.GN'] + a.data['N.FN']).localeCompare(b.data['N.GN'] + b.data['N.FN']);
case Sort.NAME: {
const nameA= getSortName(a.data);
const nameB= getSortName(b.data);
return nameA.localeCompare(nameB);
}
case Sort.BIRTHDAY: {
const aBday = a.data['BDAY'];
const bBday = b.data['BDAY'];
@ -69,16 +73,10 @@ export const ContactsListView = (props: ContactsListProps) => {
if (!orgA && orgB) return 1;
if (!orgA && !orgB) {
const nameA = (a.data['N.GN'] + a.data['N.FN']) || "";
const nameB = (b.data['N.GN'] + b.data['N.FN']) || "";
return nameA.localeCompare(nameB);
return 0;
}
return orgA.localeCompare(orgB);
const orgCompare = orgA.localeCompare(orgB);
if (orgCompare !== 0) return orgCompare;
const nameA = (a.data['N.GN'] + a.data['N.FN']) || "";
const nameB = (b.data['N.GN'] + b.data['N.FN']) || "";
return nameA.localeCompare(nameB);
}
default:
return 0;

View file

@ -1,5 +1,4 @@
import { effect } from "@preact/signals-core";
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";
@ -22,9 +21,34 @@ import { Sort } from "src/util/constants";
import myScrollTo from "src/util/myScrollTo";
interface SidebarRootViewProps {
sideBarApi: (api: { createNewContact: () => void }) => void;
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;
@ -62,6 +86,16 @@ export const SidebarRootView = (props: SidebarRootViewProps) => {
}
}, [myHookSettings]);
// React.useEffect(() => {
// return () => {
// offSettings();
// };
// }, []);
React.useEffect(() => {
props.sideBarApi({ createNewContact });
}, []);
React.useEffect(() => {
const updateFiles = (file: TFile) => {
@ -99,6 +133,12 @@ export const SidebarRootView = (props: SidebarRootViewProps) => {
};
}, [workspace]);
async function createNewContact() {
const records = await vcard.createEmpty();
const mdContent = mdRender(records, mySettings.defaultHashtag);
createContactFile(app, mySettings.contactsFolder, mdContent, createFileName(records))
}
return (
<div className="contacts-sidebar">
{ displayInsightsView ?
@ -114,15 +154,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, mySettings.defaultHashtag);
createContactFile(app, mySettings.contactsFolder, mdContent, createFileName(record))
}
}
await importVCFContacts(fileContent, app, mySettings);
})
}}
exportAllVCF={async() => {
@ -133,11 +165,7 @@ export const SidebarRootView = (props: SidebarRootViewProps) => {
})
saveVcardFilePicker(vcards)
}}
onCreateContact={async () => {
const records = await vcard.createEmpty();
const mdContent = mdRender(records, mySettings.defaultHashtag);
createContactFile(app, mySettings.contactsFolder, mdContent, createFileName(records))
}}
onCreateContact={createNewContact}
setDisplayInsightsView={setDisplayInsightsView}
sort={sort}
/>
@ -176,10 +204,10 @@ export const SidebarRootView = (props: SidebarRootViewProps) => {
<div className="action-card">
<div className="action-card-content">
<p>
Your contacts folder is currently set to the <strong>root of your vault</strong>. We advise to create a specific folder prevent system processing.
Your contacts folder is currently set to the <strong>root of your vault</strong>. We recommend setting to a specific folder to reduce processing requirements.
</p>
<p>
<button onClick={props.createDefaultPluginFolder} className="mod-cta action-card-button">Make contacts folder</button>
<button onClick={props.createDefaultPluginFolder} className="mod-cta action-card-button">Make Contacts folder</button>
</p>
</div>
</div>

View file

@ -1,4 +1,5 @@
import { ItemView, Notice, WorkspaceLeaf } from "obsidian";
import { createRef } from "react";
import * as React from "react";
import { createRoot } from "react-dom/client";
import { clearApp, setApp } from "src/context/sharedAppContext";
@ -7,9 +8,15 @@ import ContactsPlugin from "src/main";
import { SidebarRootView } from "src/ui/sidebar/components/SidebarRootView";
import { CONTACTS_VIEW_CONFIG } from "src/util/constants";
export type SidebarAPI = {
createNewContact: () => void;
};
export class ContactsView extends ItemView {
root = createRoot(this.containerEl.children[1]);
plugin: ContactsPlugin;
private sideBarApi: { createNewContact: () => void } | null = null;
constructor(leaf: WorkspaceLeaf, plugin: ContactsPlugin) {
super(leaf);
@ -37,6 +44,10 @@ export class ContactsView extends ItemView {
}
}
createNewContact() {
this.sideBarApi?.createNewContact();
}
getViewType(): string {
return CONTACTS_VIEW_CONFIG.type;
}
@ -54,6 +65,7 @@ export class ContactsView extends ItemView {
setSettings(this.plugin.settings);
this.root.render(
<SidebarRootView
sideBarApi={(sideBarApi: SidebarAPI) => (this.sideBarApi = sideBarApi)}
createDefaultPluginFolder={this.createDefaultPluginFolder.bind(this)}
/>
);

112
src/util/nameUtils.ts Normal file
View file

@ -0,0 +1,112 @@
import { Contact } from "src/contacts";
import { VCardForObsidianRecord, VCardKind } from "src/contacts/vcard/index";
import { VCardKinds } from "src/contacts/vcard/shared/structuredFields";
/**
* Doing our best for the user with minimal code to
* clean up the filename.
* @param input
*/
function sanitizeFileName(input: string) {
const illegalRe = /[\/\?<>\\:\*\|"]/g;
const controlRe = /[\x00-\x1f\x80-\x9f]/g;
const reservedRe = /^\.+$/;
const windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i;
const windowsTrailingRe = /[\. ]+$/;
const multipleSpacesRe = /\s+/g;
return input
.replace(illegalRe, ' ')
.replace(controlRe, ' ')
.replace(reservedRe, ' ')
.replace(windowsReservedRe, ' ')
.replace(windowsTrailingRe, ' ')
.replace(multipleSpacesRe, " ")
.trim();
}
/**
* Creates a name slug from vCard records. FN is a mandatory
* field in the spec so we fall back to that.
*/
export function createNameSlug(
record: VCardForObsidianRecord
): string {
let fileName: string | undefined = undefined;
if(isKind(record, VCardKinds.Individual)) {
fileName = [
record["N.PREFIX"],
record["N.GN"],
record["N.MN"],
record["N.FN"],
record["N.SUFFIX"],
]
.map((part) => part?.trim())
.filter((part) => part)
.join(" ") || undefined;
}
if(!fileName && record["FN"]) {
fileName = record["FN"];
}
if (!fileName) {
throw new Error(`Failed to update, create file name due to missing FN property"`);
}
return sanitizeFileName(fileName)
}
export function getSortName (contact:VCardForObsidianRecord): string {
if(isKind(contact, VCardKinds.Individual)) {
const name = contact["N.GN"] + contact["N.FN"];
if (!name) {
return contact["FN"]
}
return name;
}
return contact["FN"]
}
export function uiSafeString (input: unknown): string | undefined {
if (input === null || input === undefined){
return undefined;
}
if (typeof input === 'string') {
return input.trim();
} else if (typeof input === 'number' || input instanceof Date || typeof input === 'boolean') {
return input.toString();
} else {
return undefined;
}
}
export function getUiName(contact:VCardForObsidianRecord): string {
if (isKind(contact, VCardKinds.Individual)) {
const myName = [
contact["N.PREFIX"],
contact["N.GN"],
contact["N.MN"],
contact["N.FN"],
contact["N.SUFFIX"]
]
.map(uiSafeString)
.filter((value) => value !== undefined)
.join(' ')
if(myName.length > 0) {
return myName;
}
}
return uiSafeString(contact["FN"]) || '';
}
export function isKind(record: VCardForObsidianRecord, kind: VCardKind): boolean {
const myKind = record["KIND"] || VCardKinds.Individual
return myKind === kind;
}

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

@ -0,0 +1,20 @@
import { createFileName } from 'src/file/file';
import { createNameSlug } from "src/util/nameUtils";
import { describe, expect, it } from 'vitest';
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('Failed to update, create file name due to missing FN property');
});
});

19
tests/fixtures/noName.frontmatter.js vendored Normal file
View file

@ -0,0 +1,19 @@
module.exports = {
"PHOTO": "https://raw.githubusercontent.com/broekema41/obsidian-vcf-contacts/refs/heads/master/assets/demo-data/avatars/avatar13.jpg",
"EMAIL[HOME]": "liam.green@lucky.ie",
"EMAIL[WORK]": "liam@storycraft.ie",
"TEL[CELL]": "+353851234567",
"TEL[SKYPE]": "+353851234568",
"TEL[UK]": "+14165556666",
"BDAY": "1986-03-17",
"URL[HOME]": "https://liam.ie",
"URL[WORK]": "https://storycraft.ie/liam",
"ORG": "StoryCraft Mythology Guild",
"ADR[HOME].STREET": "18 Clover Court",
"ADR[HOME].LOCALITY": "Dublin",
"ADR[HOME].POSTAL": "D02",
"ADR[HOME].COUNTRY": "Ireland",
"CATEGORIES": "Writing, Mythology",
"UID": "urn:uuid:019730a76c15f-4766-ac46-2bf71efd8446",
"VERSION": "4.0"
}

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

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

@ -0,0 +1,101 @@
import { VCardKinds } from "src/contacts/vcard/shared/structuredFields";
import { createNameSlug, isKind } from "src/util/nameUtils";
import { describe, expect, it } from 'vitest';
describe('nameUtils', () => {
describe('createNameSlug', () => {
it('should create slug from N fields', () => {
const record = {
'N.PREFIX': 'Dr.',
'N.GN': 'John',
'N.MN': 'Q',
'N.FN': 'Smith',
'N.SUFFIX': 'Jr.'
};
expect(createNameSlug(record)).toBe('Dr. John Q Smith Jr');
});
it('should handle partial N fields', () => {
const record = {
'N.GN': 'Jane',
'N.FN': 'Doe'
};
expect(createNameSlug(record)).toBe('Jane Doe');
});
it('should fallback to FN when N fields are empty', () => {
const record = {
'N.PREFIX': '',
'N.GN': '',
'N.FN': '',
'FN': 'Acme Corporation'
};
expect(createNameSlug(record)).toBe('Acme Corporation');
});
it('should fallback to ORG when N and FN are empty', () => {
const record = {
'ORG': 'Tech Solutions Inc'
};
expect(() => createNameSlug(record)).toThrow();
});
it('should return undefined when no name data exists', () => {
const record = {};
expect(() => createNameSlug(record)).toThrow();
});
it('should sanitize problematic filename characters', () => {
const record = {
'FN': 'John/Doe: <CEO>'
};
expect(createNameSlug(record)).toBe('John Doe CEO');
});
it('should handle multiple consecutive dots', () => {
const record = {
'FN': 'Company Inc...'
};
expect(createNameSlug(record)).toBe('Company Inc');
});
it('should handle names with pipe and asterisk', () => {
const record = {
'N.GN': 'John*',
'N.FN': 'Doe|Smith'
};
expect(createNameSlug(record)).toBe('John Doe Smith');
});
});
describe('isKind', () => {
it('should be able to test the different types', () => {
expect(isKind({ 'KIND': 'group'}, VCardKinds.Group)).toBe(true);
expect(isKind({ 'KIND': 'individual'}, VCardKinds.Individual)).toBe(true);
expect(isKind({ 'KIND': 'org'}, VCardKinds.Organisation)).toBe(true);
});
it('should default to individual when no clear indicators', () => {
expect(isKind({},VCardKinds.Individual)).toBe(true);
expect(isKind({ 'FN': 'Some Name'}, VCardKinds.Individual)).toBe(true);
expect(isKind({ 'FN': 'Some Name'}, VCardKinds.Group)).toBe(false);
expect(isKind({ 'FN': 'Some Name'}, VCardKinds.Organisation)).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(isKind(macOSOrg, VCardKinds.Organisation)).toBe(false);
});
});
});

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,19 @@
import { App, TFile } from "obsidian";
import { vcard } from "src/contacts/vcard";
import { vcard, VCardForObsidianRecord } from "src/contacts/vcard";
import { VCardKinds } from "src/contacts/vcard/shared/structuredFields";
import { setApp } from "src/context/sharedAppContext";
import { NamingPayload } from "src/ui/modals/contactNameModal";
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) => {
@ -17,17 +27,20 @@ setApp({
vi.mock('src/ui/modals/contactNameModal', () => {
class ContactNameModal {
private callback: (givenName: string, familyName: string) => void;
private callback: (nameData: NamingPayload) => void;
constructor(
app: any,
vcfFn: string | undefined,
callback: (givenName: string, familyName: string) => void
callback: (nameData: NamingPayload) => void
) {
this.callback = callback;
}
open() {
this.callback('Foo', 'Bar');
this.callback({
kind: VCardKinds.Individual,
given: 'Foo',
family: 'Bar'
});
}
onOpen() {}
onClose() {}
@ -51,21 +64,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 +87,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 +96,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 +142,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 +154,56 @@ 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 skip the contact for organizations without FN', async () => {
const vcf = fixtures.readVcfFixture('organizationNoFN.vcf');
const result = await parseValidVCards(vcf);
expect(result).toEqual([]);
});
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();
});
});
@ -161,13 +222,24 @@ describe('vcard tostring', () => {
expect(emailLines[0]).toMatch(/TYPE=HOME/);
expect(emailLines[1]).toMatch(/TYPE=WORK/);
// FN should include the file name (FN:base.frontmatter)
expect(vcards).toMatch(/^FN:Liam OReilly$/m);
expect(vcards).toMatch(/^BEGIN:VCARD$/m);
expect(vcards).toMatch(/^END:VCARD$/m);
});
it('should export with FN if there is no naming given at all.', async () => {
const result = await vcard.toString([{ basename: 'noName.frontmatter' } as TFile]);
const { vcards, errors } = result;
expect(errors).toEqual([]);
expect(vcards).toMatch(/^ADR;TYPE=HOME:;;18 Clover Court;Dublin;;D02;Ireland$/m);
// FN should include the file name if none is given (FN:base.frontmatter)
expect(vcards).toMatch(/^FN:noName\.frontmatter$/m);
expect(vcards).toMatch(/^BEGIN:VCARD$/m);
expect(vcards).toMatch(/^END:VCARD$/m);
});
it('should be able revert the indexed fields to lines', async () => {
const result = await vcard.toString([{ basename: 'hasDuplicateParameters.frontmatter' } as TFile]);
const { vcards, errors } = result;

View file

@ -3,5 +3,6 @@
"2.0.1": "1.1.0",
"2.0.2": "1.1.0",
"2.0.3": "1.1.0",
"2.1.0": "1.4.10"
"2.1.0": "1.4.10",
"2.2.0": "1.4.10"
}