mirror of
https://github.com/broekema41/obsidian-vcf-contacts.git
synced 2026-07-22 05:42:58 +00:00
Refs #8: mergin in master so that we can contignue dev on this feature
This commit is contained in:
commit
200e6c0d62
37 changed files with 835 additions and 215 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,3 +21,6 @@ data.json
|
|||
|
||||
# Exclude macOS Finder (System Explorer) View States
|
||||
.DS_Store
|
||||
|
||||
# Claude
|
||||
.claude
|
||||
|
|
|
|||
17
README.md
17
README.md
|
|
@ -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.
|
||||
|
|
|
|||
BIN
assets/demo-data/avatars/avatar17.jpg
Normal file
BIN
assets/demo-data/avatars/avatar17.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/demo-data/avatars/avatar18.jpg
Normal file
BIN
assets/demo-data/avatars/avatar18.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
assets/demo-data/avatars/avatar19.jpg
Normal file
BIN
assets/demo-data/avatars/avatar19.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
19
assets/demo-data/markdown/India Gate (1931).md
Normal file
19
assets/demo-data/markdown/India Gate (1931).md
Normal file
File diff suppressed because one or more lines are too long
18
assets/demo-data/markdown/Snip & Sip AB.md
Normal file
18
assets/demo-data/markdown/Snip & Sip AB.md
Normal file
File diff suppressed because one or more lines are too long
18
assets/demo-data/markdown/kite surf spot zandmotor.md
Normal file
18
assets/demo-data/markdown/kite surf spot zandmotor.md
Normal 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
|
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,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];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
3
src/contacts/vcard/shared/vcard.d.ts
vendored
3
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",
|
||||
|
|
@ -44,3 +45,5 @@ export interface VCardToStringReply {
|
|||
vcards: string;
|
||||
errors: VCardToStringError[];
|
||||
}
|
||||
|
||||
export type VCardKind = "individual" | "org" | "group" | "location";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
28
src/main.ts
28
src/main.ts
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
112
src/util/nameUtils.ts
Normal 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
20
tests/file.spec.ts
Normal 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
19
tests/fixtures/noName.frontmatter.js
vendored
Normal 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
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
|
||||
101
tests/nameUtils.spec.ts
Normal file
101
tests/nameUtils.spec.ts
Normal 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);
|
||||
});
|
||||
|
||||
|
||||
|
||||
});
|
||||
});
|
||||
|
|
@ -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,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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue