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
|
6. **left-click** contact server sync when active
|
||||||
7. **left-click** export as .vcf file
|
7. **left-click** export as .vcf file
|
||||||
8. **left-click** process and import avatar from image 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>
|
<!-- TOC --><a name="-create-a-new-contact"></a>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { parseYaml, stringifyYaml, TFile } from "obsidian";
|
import { parseYaml, stringifyYaml, TFile } from "obsidian";
|
||||||
import { getApp } from "src/context/sharedAppContext";
|
import { getApp } from "src/context/sharedAppContext";
|
||||||
|
|
||||||
|
import { frontMatterRender } from "./contactMdTemplate";
|
||||||
|
|
||||||
export type Contact = {
|
export type Contact = {
|
||||||
data: Record<string, any>;
|
data: Record<string, any>;
|
||||||
file: TFile;
|
file: TFile;
|
||||||
|
|
@ -21,6 +23,25 @@ export async function getFrontmatterFromFiles(files: TFile[]) {
|
||||||
return contactsData;
|
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) {
|
export async function updateFrontMatterValue(file: TFile, key: string, value: string) {
|
||||||
const app = getApp();
|
const app = getApp();
|
||||||
// The file object can be a cloned object and therefor we ask obsidian to fetch again.
|
// 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 {
|
export function mdRender(record: Record<string, any>, hashtags: string): string {
|
||||||
const { NOTE, ...recordWithoutNote } = record;
|
const { NOTE, ...recordWithoutNote } = record;
|
||||||
const groups = groupVCardFields(recordWithoutNote)
|
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';
|
export * from 'src/contacts/vcard/shared/vcard.d';
|
||||||
import { createEmpty } from 'src/contacts/vcard/createEmpty';
|
import { createEmpty } from 'src/contacts/vcard/createEmpty';
|
||||||
import { parse } from 'src/contacts/vcard/parse';
|
import { parse } from 'src/contacts/vcard/parse';
|
||||||
|
|
@ -7,4 +9,5 @@ export const vcard = {
|
||||||
parse,
|
parse,
|
||||||
toString,
|
toString,
|
||||||
createEmpty,
|
createEmpty,
|
||||||
|
addDefaultFields
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -107,7 +107,6 @@ function parseVCardLine(line: string): VCardForObsidianRecord {
|
||||||
|
|
||||||
const propKey: string = property.toUpperCase();
|
const propKey: string = property.toUpperCase();
|
||||||
|
|
||||||
|
|
||||||
let parsedData: Record<string, any> = {};
|
let parsedData: Record<string, any> = {};
|
||||||
|
|
||||||
const typeValues:string = params["type"] ? `[${params["type"].join(",")}]` : "";
|
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);
|
parsedData = parseStructuredField(propKey as keyof typeof StructuredFields, value, typeValues);
|
||||||
} else if (['BDAY', 'ANNIVERSARY'].includes(propKey)) {
|
} else if (['BDAY', 'ANNIVERSARY'].includes(propKey)) {
|
||||||
parsedData[`${propKey}${typeValues}`] = formatVCardDate(value)
|
parsedData[`${propKey}${typeValues}`] = formatVCardDate(value)
|
||||||
} else {
|
} else if (propKey.startsWith("X-")) {
|
||||||
|
parsedData[`${propKey}${typeValues}`] = value;
|
||||||
|
} else {
|
||||||
if (propKey in VCardSupportedKey) {
|
if (propKey in VCardSupportedKey) {
|
||||||
parsedData[`${propKey}${typeValues}`] = value;
|
parsedData[`${propKey}${typeValues}`] = value;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
16
src/main.ts
16
src/main.ts
|
|
@ -1,6 +1,6 @@
|
||||||
import "src/insights/insightLoading";
|
import "src/insights/insightLoading";
|
||||||
|
|
||||||
import { Plugin } from 'obsidian';
|
import {Notice, Plugin} from 'obsidian';
|
||||||
import { initSettings } from "src/context/sharedSettingsContext";
|
import { initSettings } from "src/context/sharedSettingsContext";
|
||||||
import { ContactsSettingTab } from 'src/ui/settings/settingsView';
|
import { ContactsSettingTab } from 'src/ui/settings/settingsView';
|
||||||
import { ContactsView } from "src/ui/sidebar/sidebarView";
|
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 myScrollTo from "src/util/myScrollTo";
|
||||||
|
|
||||||
import { ContactsPluginSettings } from './settings/settings.d';
|
import { ContactsPluginSettings } from './settings/settings.d';
|
||||||
|
import {vcard} from "./contacts/vcard";
|
||||||
|
|
||||||
export default class ContactsPlugin extends Plugin {
|
export default class ContactsPlugin extends Plugin {
|
||||||
settings: ContactsPluginSettings;
|
settings: ContactsPluginSettings;
|
||||||
|
|
@ -43,6 +44,19 @@ export default class ContactsPlugin extends Plugin {
|
||||||
leaf?.createNewContact()
|
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() {}
|
onunload() {}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,22 @@
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { Notice } from "obsidian";
|
||||||
import { updateSetting } from "src/context/sharedSettingsContext";
|
import { updateSetting } from "src/context/sharedSettingsContext";
|
||||||
import {ContactsPluginSettings, SyncSelected} from "src/settings/settings";
|
import {ContactsPluginSettings, SyncSelected} from "src/settings/settings";
|
||||||
import { useSettings } from "src/ui/hooks/settingsHook";
|
import { useSettings } from "src/ui/hooks/settingsHook";
|
||||||
import { Icon } from "src/ui/sidebar/components/elements/Icon";
|
import { Icon } from "src/ui/sidebar/components/elements/Icon";
|
||||||
import {DEFAULT_SETTINGS} from "../../../settings/setting";
|
import {DEFAULT_SETTINGS} from "../../../settings/setting";
|
||||||
|
import {parseKey} from "../../../contacts";
|
||||||
|
import {VCardSupportedKey} from "../../../contacts/vcard";
|
||||||
|
|
||||||
|
type ValidateAddFieldResult = {
|
||||||
|
valid: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export function CardFieldSettings() {
|
export function CardFieldSettings() {
|
||||||
const myHookSettings: ContactsPluginSettings|undefined = useSettings();
|
const myHookSettings: ContactsPluginSettings|undefined = useSettings();
|
||||||
|
|
||||||
const [inputValue, setInputValue] = React.useState<string>("");
|
const [inputValue, setInputValue] = React.useState<string>("");
|
||||||
const setAndValidate = (value: string) => {
|
const setAndUppercase = (value: string) => {
|
||||||
const upperValue = value.toUpperCase();
|
const upperValue = value.toUpperCase();
|
||||||
setInputValue(upperValue);
|
setInputValue(upperValue);
|
||||||
}
|
}
|
||||||
|
|
@ -20,22 +27,81 @@ export function CardFieldSettings() {
|
||||||
|
|
||||||
const removeFieldKey = (keyToRemove: string) => {
|
const removeFieldKey = (keyToRemove: string) => {
|
||||||
if (!myHookSettings) return;
|
if (!myHookSettings) return;
|
||||||
|
|
||||||
const updated = myHookSettings.createFieldsKeys.filter(
|
const updated = myHookSettings.createFieldsKeys.filter(
|
||||||
(k) => k !== keyToRemove
|
(k) => k !== keyToRemove
|
||||||
);
|
);
|
||||||
|
|
||||||
updateSetting("createFieldsKeys", updated);
|
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 = () => {
|
const handleAddField = () => {
|
||||||
if (!myHookSettings) return;
|
if (!myHookSettings) return;
|
||||||
|
const {valid} = validateAddField(inputValue)
|
||||||
|
if(!valid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const updated = [...myHookSettings.createFieldsKeys, inputValue];
|
const updated = [...myHookSettings.createFieldsKeys, inputValue];
|
||||||
|
|
||||||
updateSetting("createFieldsKeys", updated);
|
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 (
|
return (
|
||||||
<div className="setting-item-spacer">
|
<div className="setting-item-spacer">
|
||||||
<div className="setting-item setting-item-heading">
|
<div className="setting-item setting-item-heading">
|
||||||
|
|
@ -65,7 +131,7 @@ export function CardFieldSettings() {
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setAndValidate(e.target.value)}
|
onChange={(e) => setAndUppercase(e.target.value)}
|
||||||
placeholder="Enter field name"
|
placeholder="Enter field name"
|
||||||
/>
|
/>
|
||||||
<button onClick={handleAddField}>
|
<button onClick={handleAddField}>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,8 @@ const mockSettings: ContactsPluginSettings = {
|
||||||
syncInterval: 900,
|
syncInterval: 900,
|
||||||
authKey: '',
|
authKey: '',
|
||||||
authType: 'apikey'
|
authType: 'apikey'
|
||||||
}
|
},
|
||||||
|
createFieldsKeys:['N.FN']
|
||||||
};
|
};
|
||||||
|
|
||||||
const testDefaultSettings = {
|
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";
|
import {DEFAULT_SETTINGS} from "../src/settings/setting";
|
||||||
|
|
||||||
// Helper function to parse vCards and collect only those with valid slugs
|
// 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[] = [];
|
const cards: VCardForObsidianRecord[] = [];
|
||||||
for await (const [slug, card] of vcard.parse(vcfData))
|
for await (const [slug, card] of vcard.parse(vcfData))
|
||||||
if (slug) cards.push(card);
|
if (slug) cards.push(card);
|
||||||
|
|
@ -220,7 +220,26 @@ END:VCARD`;
|
||||||
expect(result[0]['N.GN']).toBeUndefined();
|
expect(result[0]['N.GN']).toBeUndefined();
|
||||||
expect(result[0]['N.FN']).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', () => {
|
describe('vcard tostring', () => {
|
||||||
|
|
@ -254,8 +273,6 @@ describe('vcard tostring', () => {
|
||||||
expect(vcards).toMatch(/^END:VCARD$/m);
|
expect(vcards).toMatch(/^END:VCARD$/m);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it('should be able revert the indexed fields to lines', async () => {
|
it('should be able revert the indexed fields to lines', async () => {
|
||||||
const result = await vcard.toString([{ basename: 'hasDuplicateParameters.frontmatter' } as TFile]);
|
const result = await vcard.toString([{ basename: 'hasDuplicateParameters.frontmatter' } as TFile]);
|
||||||
const { vcards, errors } = result;
|
const { vcards, errors } = result;
|
||||||
|
|
@ -278,5 +295,18 @@ describe('vcard tostring', () => {
|
||||||
expect(errors[0].message).not.toBe('');
|
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