From 8feb076c49c1e9d02edb8fce3f46abbafeb95d49 Mon Sep 17 00:00:00 2001 From: Roland Date: Fri, 17 Apr 2026 13:36:18 +0200 Subject: [PATCH] Refs #17 add defaults to current file implemented as command p action, validation in default field inputs --- README.md | 24 ++++++ src/contacts/contactFrontmatter.ts | 21 +++++ src/contacts/contactMdTemplate.ts | 14 ++++ src/contacts/vcard/addDefaultFields.ts | 29 +++++++ src/contacts/vcard/index.ts | 3 + src/contacts/vcard/parse.ts | 5 +- src/main.ts | 16 +++- .../settings/components/cardFieldSettings.tsx | 76 +++++++++++++++++-- tests/context.spec.ts | 3 +- tests/fixtures/xprops.frontmatter.js | 9 +++ tests/vcard.spec.ts | 38 +++++++++- 11 files changed, 225 insertions(+), 13 deletions(-) create mode 100644 src/contacts/vcard/addDefaultFields.ts create mode 100644 tests/fixtures/xprops.frontmatter.js diff --git a/README.md b/README.md index 61dc0e9..935c3bc 100644 --- a/README.md +++ b/README.md @@ -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 Obsidian’s 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. + --- diff --git a/src/contacts/contactFrontmatter.ts b/src/contacts/contactFrontmatter.ts index a4df747..b78e3ec 100644 --- a/src/contacts/contactFrontmatter.ts +++ b/src/contacts/contactFrontmatter.ts @@ -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; file: TFile; @@ -21,6 +23,25 @@ export async function getFrontmatterFromFiles(files: TFile[]) { return contactsData; } +export async function updateFrontMatter(file: TFile, record: Record) { + 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. diff --git a/src/contacts/contactMdTemplate.ts b/src/contacts/contactMdTemplate.ts index 5a87289..191d621 100644 --- a/src/contacts/contactMdTemplate.ts +++ b/src/contacts/contactMdTemplate.ts @@ -67,6 +67,20 @@ function sortedPriorityItems(record: Record) { ); } +export function frontMatterRender(record: Record) { + 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, hashtags: string): string { const { NOTE, ...recordWithoutNote } = record; const groups = groupVCardFields(recordWithoutNote) diff --git a/src/contacts/vcard/addDefaultFields.ts b/src/contacts/vcard/addDefaultFields.ts new file mode 100644 index 0000000..24b5325 --- /dev/null +++ b/src/contacts/vcard/addDefaultFields.ts @@ -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 = { ...frontMatter }; + + let changed = false; + + for (const key of defaultFieldKeys) { + if (!(key in record)) { + record[key] = ""; + changed = true; + } + } + + if (!changed) return; + + await updateFrontMatter(file, record); + +} diff --git a/src/contacts/vcard/index.ts b/src/contacts/vcard/index.ts index 1122ffe..b8aae42 100644 --- a/src/contacts/vcard/index.ts +++ b/src/contacts/vcard/index.ts @@ -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 }; diff --git a/src/contacts/vcard/parse.ts b/src/contacts/vcard/parse.ts index 9b0f02a..05b68cc 100644 --- a/src/contacts/vcard/parse.ts +++ b/src/contacts/vcard/parse.ts @@ -107,7 +107,6 @@ function parseVCardLine(line: string): VCardForObsidianRecord { const propKey: string = property.toUpperCase(); - let parsedData: Record = {}; 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; } diff --git a/src/main.ts b/src/main.ts index 9f2a34a..48909ba 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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() {} diff --git a/src/ui/settings/components/cardFieldSettings.tsx b/src/ui/settings/components/cardFieldSettings.tsx index 76a9abc..0032ac1 100644 --- a/src/ui/settings/components/cardFieldSettings.tsx +++ b/src/ui/settings/components/cardFieldSettings.tsx @@ -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(""); - 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}" isn’t 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 (
@@ -65,7 +131,7 @@ export function CardFieldSettings() { setAndValidate(e.target.value)} + onChange={(e) => setAndUppercase(e.target.value)} placeholder="Enter field name" />