From 66eb591ced01d916ccbce6fa80cb8a3be68ab52f Mon Sep 17 00:00:00 2001 From: Roland Broekema Date: Tue, 10 Jun 2025 09:48:56 +0200 Subject: [PATCH] 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 --- src/contacts/contactMdTemplate.ts | 75 +++++++++++++++++++- src/contacts/vcard/createEmpty.ts | 4 +- src/contacts/vcard/parse.ts | 7 +- src/contacts/vcard/shared/ensureHasName.ts | 9 +++ src/contacts/vcard/shared/sortVcardFields.ts | 73 ------------------- tests/fixtures/fixtures.ts | 11 +++ tests/fixtures/noFirstName.vcf | 19 +++++ tests/vcard.spec.ts | 58 +++++++++++---- 8 files changed, 160 insertions(+), 96 deletions(-) delete mode 100644 src/contacts/vcard/shared/sortVcardFields.ts create mode 100644 tests/fixtures/fixtures.ts create mode 100644 tests/fixtures/noFirstName.vcf diff --git a/src/contacts/contactMdTemplate.ts b/src/contacts/contactMdTemplate.ts index 2d63585..4841aa6 100644 --- a/src/contacts/contactMdTemplate.ts +++ b/src/contacts/contactMdTemplate.ts @@ -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) { + 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) { + 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) { + 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, 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, 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} diff --git a/src/contacts/vcard/createEmpty.ts b/src/contacts/vcard/createEmpty.ts index 24054c5..c8c7dd0 100644 --- a/src/contacts/vcard/createEmpty.ts +++ b/src/contacts/vcard/createEmpty.ts @@ -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 = { @@ -24,6 +23,5 @@ export async function createEmpty() { "CATEGORIES": "", "VERSION": "4.0" } - const checkedNameVCardObject = await ensureHasName(vCardObject); - return sortVCardOFields(checkedNameVCardObject); + return await ensureHasName(vCardObject); } diff --git a/src/contacts/vcard/parse.ts b/src/contacts/vcard/parse.ts index 90be528..1672d16 100644 --- a/src/contacts/vcard/parse.ts +++ b/src/contacts/vcard/parse.ts @@ -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 = {}; 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 { + 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(); } diff --git a/src/contacts/vcard/shared/sortVcardFields.ts b/src/contacts/vcard/shared/sortVcardFields.ts deleted file mode 100644 index a0e104c..0000000 --- a/src/contacts/vcard/shared/sortVcardFields.ts +++ /dev/null @@ -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 }; -} diff --git a/tests/fixtures/fixtures.ts b/tests/fixtures/fixtures.ts new file mode 100644 index 0000000..1f1a4b6 --- /dev/null +++ b/tests/fixtures/fixtures.ts @@ -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 +} diff --git a/tests/fixtures/noFirstName.vcf b/tests/fixtures/noFirstName.vcf new file mode 100644 index 0000000..5ebe229 --- /dev/null +++ b/tests/fixtures/noFirstName.vcf @@ -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 diff --git a/tests/vcard.spec.ts b/tests/vcard.spec.ts index 7969a80..3b135e0 100644 --- a/tests/vcard.spec.ts +++ b/tests/vcard.spec.ts @@ -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 () => { + + }); + +}); +