extracted the sorting algorith and moved out of the vcard module at this is only important for the md render and we can guarantee the sorting is maintained this way

This commit is contained in:
Roland Broekema 2025-06-10 09:48:56 +02:00
parent a01db62ede
commit 66eb591ced
8 changed files with 160 additions and 96 deletions

View file

@ -1,7 +1,76 @@
import { stringifyYaml } from "obsidian";
const nameKeys = ["N", "FN"];
const priorityKeys = [
"EMAIL", "TEL", "BDAY","URL",
"ORG", "TITLE", "ROLE", "PHOTO"
];
const adrKeys = [
"ADR"
];
function extractBaseKey(key: string): string {
if (key.includes("[")) {
return key.split("[")[0];
} else if (key.includes(".")) {
return key.split(".")[0];
}
return key;
}
function groupVCardFields(record: Record<string, any>) {
const groups:{
name: { [key: string]: any };
priority: { [key: string]: any };
address: { [key: string]: any };
other: { [key: string]: any };
} = {
name: {},
priority: {},
address: {},
other: {}
};
Object.entries(record).forEach(([key, value]) => {
const baseKey = extractBaseKey(key);
if (nameKeys.includes(baseKey)) {
groups.name[key] = value;
} else if (priorityKeys.includes(baseKey)) {
groups.priority[key] = value;
} else if (adrKeys.includes(baseKey)) {
groups.address[key] = value;
} else {
groups.other[key] = value;
}
});
return groups;
}
function sortNameItems(record: Record<string, any>) {
const nameSortOrder = ['N.PREFIX', 'N.GN', 'N.MN', 'N.FN', 'N.SUFFIX', 'FN'];
return stringifyYaml(Object.fromEntries(
Object.entries(record).sort(([keyA], [keyB]) => {
return nameSortOrder.indexOf(keyA) - nameKeys.indexOf(keyB);
})
));
}
function sortedPriorityItems(record: Record<string, any>) {
return stringifyYaml(Object.fromEntries(
Object.entries(record).sort(([keyA], [keyB]) => {
const baseKeyA = extractBaseKey(keyA);
const baseKeyB = extractBaseKey(keyB);
return priorityKeys.indexOf(baseKeyA) - priorityKeys.indexOf(baseKeyB);
})
));
}
export function mdRender(record: Record<string, any>, hashtags: string): string {
const { NOTE, ...recordWithoutNote } = record;
const groups = groupVCardFields(recordWithoutNote)
const myNote = NOTE ? NOTE.replace(/\\n/g, `
`) : '';
let additionalTags = ''
@ -9,10 +78,12 @@ export function mdRender(record: Record<string, any>, hashtags: string): string
const tempTags= recordWithoutNote.CATEGORIES.split(',')
additionalTags = `#${tempTags.join(' #')}`
}
const yamlString = stringifyYaml(recordWithoutNote);
return `---
${yamlString}
${sortNameItems(groups.name)}
${sortedPriorityItems(groups.priority)}
${stringifyYaml(groups.address)}
${stringifyYaml(groups.other)}
---
#### Notes
${myNote}

View file

@ -1,5 +1,4 @@
import { ensureHasName } from "src/contacts/vcard/shared/ensureHasName";
import { sortVCardOFields } from "src/contacts/vcard/shared/sortVcardFields";
export async function createEmpty() {
const vCardObject: Record<string, any> = {
@ -24,6 +23,5 @@ export async function createEmpty() {
"CATEGORIES": "",
"VERSION": "4.0"
}
const checkedNameVCardObject = await ensureHasName(vCardObject);
return sortVCardOFields(checkedNameVCardObject);
return await ensureHasName(vCardObject);
}

View file

@ -1,6 +1,6 @@
import { VCardForObsidianRecord, VCardSupportedKey } from "src/contacts/vcard";
import { ensureHasName } from "src/contacts/vcard/shared/ensureHasName";
import { sortVCardOFields } from "src/contacts/vcard/shared/sortVcardFields";
import { sortVCardOFields } from "src/contacts/sortVcardFields";
import { StructuredFields } from "src/contacts/vcard/shared/structuredFields";
import { photoLineFromV3toV4 } from "src/util/photoLineFromV3toV4";
@ -111,7 +111,7 @@ function parseVCardLine(line: string): VCardForObsidianRecord {
let parsedData: Record<string, any> = {};
const typeValues:string = params["type"] ? `[${params["type"].join(",")}]` : "";
if (key.contains('PHOTO') && key.contains('ENCODING=BASE64')) {
if (key.includes('PHOTO') && key.includes('ENCODING=BASE64')) {
parsedData['PHOTO'] = photoLineFromV3toV4(line);
} else if (key === 'VERSION') {
parsedData['VERSION'] = '4.0';
@ -165,8 +165,7 @@ export async function parse(vCardData: string): Promise<VCardForObsidianRecord[]
Object.assign(vCardObject, indexedParsedLine);
}
}
const checkedNameVCardObject = await ensureHasName(vCardObject);
return sortVCardOFields(checkedNameVCardObject);
return await ensureHasName(vCardObject);
}));
}

View file

@ -9,8 +9,17 @@ export async function ensureHasName(vCardObject: VCardForObsidianRecord): Promis
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'] = '';
}
resolve(vCardObject);
}).open();
}

View file

@ -1,73 +0,0 @@
import { VCardForObsidianRecord } from "src/contacts/vcard";
/**
* Extracts the base key from a vCard field name.
* - If the key contains `[`, extract everything before it.
* - Else if the key contains `.`, extract everything before the first dot.
* - Otherwise, return the key as is.
* @param key The full vCard field key.
* @returns The extracted base key.
*/
function extractBaseKey(key: string): string {
if (key.includes("[")) {
return key.split("[")[0];
} else if (key.includes(".")) {
return key.split(".")[0];
}
return key;
}
/**
* Sorts a vCard object:
* - Moves priority fields (e.g., `N`, `FN`, `EMAIL`, `TEL`) to the top.
* - Places `ADR` fields **after** `BDAY`.
* - Sorts indexed fields (`[1:]`, `[1:TYPE]`) in order.
* @param vCardObject The parsed vCard object.
* @returns A sorted vCard object.
*/
export function sortVCardOFields(vCardObject: VCardForObsidianRecord): VCardForObsidianRecord {
// Define sorting priority
const priorityOrder = [
"N", "FN", "PHOTO",
"EMAIL", "TEL",
"BDAY",
"ADR", "URL",
"ORG", "TITLE", "ROLE"
];
// Separate priority and other fields
const priorityEntries: VCardForObsidianRecord = {};
const adrEntries: VCardForObsidianRecord = {};
const otherEntries: VCardForObsidianRecord = {};
Object.entries(vCardObject).forEach(([key, value]) => {
const baseKey = extractBaseKey(key);
if (priorityOrder.includes(baseKey)) {
if (baseKey === "ADR") {
adrEntries[key] = value; // Keep ADR separate to place after BDAY
} else {
priorityEntries[key] = value;
}
} else {
otherEntries[key] = value;
}
});
// Sort priority entries based on priority order
const sortedPriorityEntries = Object.fromEntries(
Object.entries(priorityEntries).sort(([keyA], [keyB]) => {
const baseKeyA = extractBaseKey(keyA);
const baseKeyB = extractBaseKey(keyB);
return priorityOrder.indexOf(baseKeyA) - priorityOrder.indexOf(baseKeyB);
})
);
// Sort non-priority fields alphabetically while preserving indexes
const sortedOtherEntries = Object.fromEntries(
Object.entries(otherEntries).sort(([a], [b]) => a.localeCompare(b))
);
return { ...sortedPriorityEntries, ...adrEntries, ...sortedOtherEntries };
}

11
tests/fixtures/fixtures.ts vendored Normal file
View file

@ -0,0 +1,11 @@
import { readFileSync } from 'fs';
import { join } from 'path';
export function readVcfFixture(fileName: string): string {
const filePath = join(__dirname, fileName);
return readFileSync(filePath, 'utf8');
}
export const fixtures = {
readVcfFixture
}

19
tests/fixtures/noFirstName.vcf vendored Normal file
View file

@ -0,0 +1,19 @@
BEGIN:VCARD
N:;Zahra;;;
ADR;TYPE=HOME:;;2 Sand Dune Street;Dubai;;00000;UAE
FN:Zahra Ali
FN:Zahra Ali
EMAIL;TYPE=HOME:zahra.moon@miragemail.ae
EMAIL;TYPE=WORK:zahra@dreambuild.ae
TEL;TYPE=CELL:+971501234567
TEL;TYPE=BOTIM:+971501234568
TEL;TYPE=CANADA:+14165553333
BDAY:1990-08-08
URL;TYPE=HOME:https://zahra.ae
URL;TYPE=WORK:https://dreambuild.ae/zahra
ORG:Mirage Dream Builders
ROLE:Lead Vision Architect
CATEGORIES:Architecture, Stars
VERSION:4.0
UID:urn:uuid:019730a76c182-4191-824c-9d9607f52656
END:VCARD

View file

@ -1,6 +1,7 @@
import { App } from "obsidian";
import { vcard } from "src/contacts/vcard";
import { setApp } from "src/context/sharedAppContext";
import { fixtures } from "tests/fixtures/fixtures";
import { describe, expect, it, vi } from 'vitest';
setApp({} as App);
@ -26,22 +27,51 @@ vi.mock('src/ui/modals/contactNameModal', () => {
return { ContactNameModal };
});
describe('vcard creatEmpty', () => {
it('should ask for a firstname and lastname ', async () => {
const empty = await vcard.createEmpty();
expect(empty).toBeDefined();
expect(empty).toEqual(expect.objectContaining({
'N.PREFIX': '',
'N.GN': 'Foo',
'N.MN': '',
'N.FN': 'Bar',
'N.SUFFIX': ''
}));
const keys = Object.keys(empty);
const expectedNFields = ['N.PREFIX', 'N.GN', 'N.MN', 'N.FN', 'N.SUFFIX'];
expect(keys.slice(0, expectedNFields.length)).toEqual(expectedNFields);
const expectedFields = ['N.PREFIX', 'N.GN', 'N.MN', 'N.FN', 'N.SUFFIX'];
expectedFields.forEach((field) => {
expect(empty).toHaveProperty(field);
});
expect(empty['N.GN']).toBe('Foo');
expect(empty['N.FN']).toBe('Bar');
});
});
describe('vcard parse', () => {
it('Should ensure all the name variables exist and first name and lastname are filled', 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');
});
it('should only import variables that are in a predifined list ', async () => {
});
it('should convert v3 internal phoro to a v4 version', async () => {
});
it('should be able to parse multiple cards from one file', async () => {
});
it('should add indexes to duplicate field names.', async () => {
});
it('should preform dome sorting and try to unify dates. ', async () => {
});
});