mirror of
https://github.com/broekema41/obsidian-vcf-contacts.git
synced 2026-07-22 05:42:58 +00:00
Refs #17 add defaults to current file implemented as command p action, validation in default field inputs
This commit is contained in:
parent
51ee6c8737
commit
8feb076c49
11 changed files with 225 additions and 13 deletions
24
README.md
24
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.
|
||||
|
||||
---
|
||||
|
||||
<!-- TOC --><a name="-create-a-new-contact"></a>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
29
src/contacts/vcard/addDefaultFields.ts
Normal file
29
src/contacts/vcard/addDefaultFields.ts
Normal 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);
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
16
src/main.ts
16
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() {}
|
||||
|
|
|
|||
|
|
@ -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}" 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 (
|
||||
<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}>
|
||||
|
|
|
|||
|
|
@ -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
9
tests/fixtures/xprops.frontmatter.js
vendored
Normal 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"
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue