Refs #17 add defaults to current file implemented as command p action, validation in default field inputs

This commit is contained in:
Roland 2026-04-17 13:36:18 +02:00
parent 51ee6c8737
commit 8feb076c49
11 changed files with 225 additions and 13 deletions

View file

@ -145,6 +145,30 @@ You can attach profile pictures using:
6. **left-click** contact server sync when active
7. **left-click** export as .vcf file
8. **left-click** process and import avatar from image file
---
### ⚡ Quick Actions
The plugin provides command-based quick actions that can be accessed through Obsidians Command Palette.
Open the Command Palette with:
- **Ctrl + P** (Windows/Linux)
- **Cmd + P** (macOS)
Search for **“VCF Contacts”** to see all available commands.
1) **VCF Contacts: Create Contact**
Create a new contact using your configured default fields.
2) **VCF Contacts: Open Contacts Sidebar**
Open the contacts sidebar view.
3) **VCF Contacts: Apply Default Fields to Current File**
Adds any missing default fields (as defined in **Settings → Default Contact Fields**) to the currently open contact file.
Existing fields are not overwritten.
---
<!-- TOC --><a name="-create-a-new-contact"></a>

View file

@ -1,6 +1,8 @@
import { parseYaml, stringifyYaml, TFile } from "obsidian";
import { getApp } from "src/context/sharedAppContext";
import { frontMatterRender } from "./contactMdTemplate";
export type Contact = {
data: Record<string, any>;
file: TFile;
@ -21,6 +23,25 @@ export async function getFrontmatterFromFiles(files: TFile[]) {
return contactsData;
}
export async function updateFrontMatter(file: TFile, record: Record<string, any>) {
const app = getApp();
// The file object can be a cloned object and therefor we ask obsidian to fetch again.
const myFile = app.vault.getAbstractFileByPath(file.path);
if (!myFile || !(myFile instanceof TFile)) {
throw new Error("while updating the frontmatter file should always exist even if the TFile is cloned");
}
const content = await app.vault.read(myFile);
const match = content.match(/^---\n([\s\S]*?)\n---\n?/);
if (!match) {
return;
}
const body = content.slice(match[0].length);
const newFrontMatter= frontMatterRender(record);
const newContent = newFrontMatter + body;
await app.vault.modify(myFile, newContent);
}
export async function updateFrontMatterValue(file: TFile, key: string, value: string) {
const app = getApp();
// The file object can be a cloned object and therefor we ask obsidian to fetch again.

View file

@ -67,6 +67,20 @@ function sortedPriorityItems(record: Record<string, any>) {
);
}
export function frontMatterRender(record: Record<string, any>) {
const { NOTE, ...recordWithoutNote } = record;
const groups = groupVCardFields(recordWithoutNote)
const frontmatter = {
...sortNameItems(groups.name),
...sortedPriorityItems(groups.priority),
...groups.address,
...groups.other
};
return `---
${stringifyYaml(frontmatter)}---
`;
}
export function mdRender(record: Record<string, any>, hashtags: string): string {
const { NOTE, ...recordWithoutNote } = record;
const groups = groupVCardFields(recordWithoutNote)

View file

@ -0,0 +1,29 @@
import { TFile } from "obsidian";
import { updateFrontMatter } from "src/contacts";
import { getApp } from "src/context/sharedAppContext";
import { getSettings } from "src/context/sharedSettingsContext";
export async function addDefaultFields(file: TFile) {
const defaultFieldKeys = getSettings().createFieldsKeys
const { metadataCache } = getApp();
const frontMatter = metadataCache.getFileCache(file)?.frontmatter;
if (!frontMatter) {
throw new Error('No frontmatter found.');
}
const record: Record<string, any> = { ...frontMatter };
let changed = false;
for (const key of defaultFieldKeys) {
if (!(key in record)) {
record[key] = "";
changed = true;
}
}
if (!changed) return;
await updateFrontMatter(file, record);
}

View file

@ -1,3 +1,5 @@
import {addDefaultFields} from "src/contacts/vcard/addDefaultFields";
export * from 'src/contacts/vcard/shared/vcard.d';
import { createEmpty } from 'src/contacts/vcard/createEmpty';
import { parse } from 'src/contacts/vcard/parse';
@ -7,4 +9,5 @@ export const vcard = {
parse,
toString,
createEmpty,
addDefaultFields
};

View file

@ -107,7 +107,6 @@ function parseVCardLine(line: string): VCardForObsidianRecord {
const propKey: string = property.toUpperCase();
let parsedData: Record<string, any> = {};
const typeValues:string = params["type"] ? `[${params["type"].join(",")}]` : "";
@ -120,7 +119,9 @@ function parseVCardLine(line: string): VCardForObsidianRecord {
parsedData = parseStructuredField(propKey as keyof typeof StructuredFields, value, typeValues);
} else if (['BDAY', 'ANNIVERSARY'].includes(propKey)) {
parsedData[`${propKey}${typeValues}`] = formatVCardDate(value)
} else {
} else if (propKey.startsWith("X-")) {
parsedData[`${propKey}${typeValues}`] = value;
} else {
if (propKey in VCardSupportedKey) {
parsedData[`${propKey}${typeValues}`] = value;
}

View file

@ -1,6 +1,6 @@
import "src/insights/insightLoading";
import { Plugin } from 'obsidian';
import {Notice, Plugin} from 'obsidian';
import { initSettings } from "src/context/sharedSettingsContext";
import { ContactsSettingTab } from 'src/ui/settings/settingsView';
import { ContactsView } from "src/ui/sidebar/sidebarView";
@ -8,6 +8,7 @@ import { CONTACTS_VIEW_CONFIG } from "src/util/constants";
import myScrollTo from "src/util/myScrollTo";
import { ContactsPluginSettings } from './settings/settings.d';
import {vcard} from "./contacts/vcard";
export default class ContactsPlugin extends Plugin {
settings: ContactsPluginSettings;
@ -43,6 +44,19 @@ export default class ContactsPlugin extends Plugin {
leaf?.createNewContact()
},
});
this.addCommand({
id: "contacts-apply-default-fields",
name: "Apply Default Fields to Current File",
callback: () => {
const file = this.app.workspace.getActiveFile();
if (!file) {
new Notice("No active file.");
return;
}
vcard.addDefaultFields(file)
}
});
}
onunload() {}

View file

@ -1,15 +1,22 @@
import * as React from "react";
import { Notice } from "obsidian";
import { updateSetting } from "src/context/sharedSettingsContext";
import {ContactsPluginSettings, SyncSelected} from "src/settings/settings";
import { useSettings } from "src/ui/hooks/settingsHook";
import { Icon } from "src/ui/sidebar/components/elements/Icon";
import {DEFAULT_SETTINGS} from "../../../settings/setting";
import {parseKey} from "../../../contacts";
import {VCardSupportedKey} from "../../../contacts/vcard";
type ValidateAddFieldResult = {
valid: boolean;
};
export function CardFieldSettings() {
const myHookSettings: ContactsPluginSettings|undefined = useSettings();
const [inputValue, setInputValue] = React.useState<string>("");
const setAndValidate = (value: string) => {
const setAndUppercase = (value: string) => {
const upperValue = value.toUpperCase();
setInputValue(upperValue);
}
@ -20,22 +27,81 @@ export function CardFieldSettings() {
const removeFieldKey = (keyToRemove: string) => {
if (!myHookSettings) return;
const updated = myHookSettings.createFieldsKeys.filter(
(k) => k !== keyToRemove
);
updateSetting("createFieldsKeys", updated);
};
const validateAddField = (fieldkey: string):ValidateAddFieldResult => {
if (!myHookSettings) {
return { valid: false };
}
if (fieldkey === '') {
showFieldRejectedNotice(
`Field name cannot be empty.`
);
return { valid: false };
}
if (myHookSettings.createFieldsKeys.includes(fieldkey)) {
showFieldRejectedNotice(
`Field "${fieldkey}" is already in the list.`
);
return { valid: false };
}
const field = parseKey(fieldkey);
const keySupported = field.key in VCardSupportedKey
const CustomProperty = field.key.startsWith("X-")
if(keySupported || CustomProperty) {
return {
valid: true
};
}
showFieldRejectedNotice(
`Field "${field.key}" isnt supported. Check the plugin README for supported fields, or prefix custom fields with X-.`
);
return {
valid: false
};
}
const handleAddField = () => {
if (!myHookSettings) return;
const {valid} = validateAddField(inputValue)
if(!valid) {
return;
}
const updated = [...myHookSettings.createFieldsKeys, inputValue];
updateSetting("createFieldsKeys", updated);
setInputValue('');
}
const showFieldRejectedNotice = (message: string) => {
const fragment = document.createDocumentFragment();
const title = document.createElement("strong");
title.textContent = "Field blocked";
title.style.color = "var(--text-error)";
const br = document.createElement("br");
const text = document.createElement("span");
text.textContent = message;
fragment.appendChild(title);
fragment.appendChild(br);
fragment.appendChild(text);
new Notice(fragment, 5000);
};
return (
<div className="setting-item-spacer">
<div className="setting-item setting-item-heading">
@ -65,7 +131,7 @@ export function CardFieldSettings() {
<input
type="text"
value={inputValue}
onChange={(e) => setAndValidate(e.target.value)}
onChange={(e) => setAndUppercase(e.target.value)}
placeholder="Enter field name"
/>
<button onClick={handleAddField}>

View file

@ -36,7 +36,8 @@ const mockSettings: ContactsPluginSettings = {
syncInterval: 900,
authKey: '',
authType: 'apikey'
}
},
createFieldsKeys:['N.FN']
};
const testDefaultSettings = {

9
tests/fixtures/xprops.frontmatter.js vendored Normal file
View file

@ -0,0 +1,9 @@
module.exports = {
"N.FN": "OReilly",
"N.GN": "Liam",
"FN": "Liam OReilly",
"X-ARCHIVED": "FALSE",
"X-FAVORITE-ICE-CREAM-FLAVOR[PERSONAL]": "Mint Chocolate Chip",
"UID": "urn:uuid:019730a76c15f-4766-ac46-2bf71efd8446",
"VERSION": "4.0"
}

View file

@ -8,7 +8,7 @@ import { describe, expect, it, vi } from 'vitest';
import {DEFAULT_SETTINGS} from "../src/settings/setting";
// Helper function to parse vCards and collect only those with valid slugs
const parseValidVCards = async (vcfData: string) => {
const parseValidVCards = async (vcfData: string) => {``
const cards: VCardForObsidianRecord[] = [];
for await (const [slug, card] of vcard.parse(vcfData))
if (slug) cards.push(card);
@ -220,7 +220,26 @@ END:VCARD`;
expect(result[0]['N.GN']).toBeUndefined();
expect(result[0]['N.FN']).toBeUndefined();
});
});
it('should parse custom X- properties with and without parameters', async () => {
const vcfWithXProps = `BEGIN:VCARD
VERSION:4.0
FN:Tech Company
ORG:Tech Company
X-ARCHIVED:FALSE
X-FAVORITE-ICE-CREAM-FLAVOR;TYPE=PERSONAL:Mint Chocolate Chip
END:VCARD`;
const result = await parseValidVCards(vcfWithXProps);
expect(result[0]['FN']).toBe('Tech Company');
expect(result[0]['ORG']).toBe('Tech Company');
expect(result[0]['X-ARCHIVED']).toBe('FALSE');
expect(result[0]['X-FAVORITE-ICE-CREAM-FLAVOR[PERSONAL]'])
.toBe('Mint Chocolate Chip');
});
});
describe('vcard tostring', () => {
@ -254,8 +273,6 @@ describe('vcard tostring', () => {
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;
@ -278,5 +295,18 @@ describe('vcard tostring', () => {
expect(errors[0].message).not.toBe('');
});
it('should include custom X- properties in vcard output', async () => {
const result = await vcard.toString([{ basename: 'xprops.frontmatter' } as TFile]);
const { vcards, errors } = result;
expect(errors).toEqual([]);
expect(vcards).toMatch(/^BEGIN:VCARD$/m);
expect(vcards).toMatch(/^VERSION:4.0$/m);
expect(vcards).toMatch(/^X-ARCHIVED:FALSE$/m);
expect(vcards).toMatch(
/^X-FAVORITE-ICE-CREAM-FLAVOR;TYPE=PERSONAL:Mint Chocolate Chip$/m
);
expect(vcards).toMatch(/^END:VCARD$/m);
});
});