From 2718178d8d00ae50510a5b46b22736a8a387d4a6 Mon Sep 17 00:00:00 2001 From: aleksejs1 Date: Tue, 27 Jan 2026 06:05:18 +0200 Subject: [PATCH] Array in property values #36 (#37) --- README.md | 1 + .../strategies/ArrayNamingStrategy.test.ts | 112 ++++++++++++++++++ src/core/Formatter.ts | 7 +- src/core/adapters/DepartmentAdapter.ts | 28 ++++- src/core/adapters/EmailAdapter.ts | 23 +++- src/core/adapters/JobTitleAdapter.ts | 23 +++- src/core/adapters/OrganizationAdapter.ts | 27 ++++- src/core/adapters/PhoneAdapter.ts | 23 +++- src/core/adapters/RelationsAdapter.ts | 31 +++-- src/core/strategies/ArrayNamingStrategy.ts | 7 ++ src/i18n/translations.ts | 3 + src/plugin/settings.ts | 1 + src/types/Settings.ts | 1 + 13 files changed, 263 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/core/strategies/ArrayNamingStrategy.test.ts create mode 100644 src/core/strategies/ArrayNamingStrategy.ts diff --git a/README.md b/README.md index 3ac546e..9b8f4aa 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Each contact becomes a separate note with YAML frontmatter for metadata and free - 🧩 Multiple naming strategies for frontmatter keys: - **Default**: Customizable prefix (e.g., `s_name`, `s_email_2`). - **VCF (vCard)**: Fully compatible with the [VCF Contacts](https://github.com/broekema41/obsidian-vcf-contacts) plugin. + - **Array**: Stores multiple values (emails, phones, etc.) as a single array field in the frontmatter. - 📇 Supports multiple names, emails, phone numbers, birthdays, addresses, organizations, job titles, department, labels, relations diff --git a/src/__tests__/core/strategies/ArrayNamingStrategy.test.ts b/src/__tests__/core/strategies/ArrayNamingStrategy.test.ts new file mode 100644 index 0000000..93d9fce --- /dev/null +++ b/src/__tests__/core/strategies/ArrayNamingStrategy.test.ts @@ -0,0 +1,112 @@ +import { createDefaultFormatter } from '../../../core/Formatter'; +import { NamingStrategy } from '../../../types/Settings'; +import { GoogleContact } from '../../../types/Contact'; + +describe('ArrayNamingStrategy Integration', () => { + const formatter = createDefaultFormatter(NamingStrategy.Array); + const contact: GoogleContact = { + resourceName: 'people/123', + emailAddresses: [ + { value: 'test1@example.com' }, + { value: 'test2@example.com' }, + ], + phoneNumbers: [{ value: '123456789' }, { value: '987654321' }], + organizations: [ + { name: 'Org1', title: 'Developer', department: 'Engineering' }, + { name: 'Org2', title: 'Manager', department: 'Management' }, + ], + relations: [ + { person: 'Jane', type: 'spouse' }, + { person: 'Bob', type: 'friend' }, + ], + } as GoogleContact; + + it('should format emails as an array', () => { + const result = formatter.generateFrontmatter(contact, 'contact_'); + expect(result.contact_email).toEqual([ + 'test1@example.com', + 'test2@example.com', + ]); + }); + + it('should format single email as string', () => { + const singleContact = { + ...contact, + emailAddresses: [{ value: 'single@example.com' }], + } as GoogleContact; + const result = formatter.generateFrontmatter(singleContact, 'contact_'); + expect(result.contact_email).toEqual('single@example.com'); + }); + + it('should format phones as an array', () => { + const result = formatter.generateFrontmatter(contact, 'contact_'); + expect(result.contact_phone).toEqual(['123456789', '987654321']); + }); + + it('should format single phone as string', () => { + const singleContact = { + ...contact, + phoneNumbers: [{ value: '111222333' }], + } as GoogleContact; + const result = formatter.generateFrontmatter(singleContact, 'contact_'); + expect(result.contact_phone).toEqual('111222333'); + }); + + it('should format organizations as an array', () => { + const result = formatter.generateFrontmatter(contact, 'contact_'); + expect(result.contact_organization).toEqual(['Org1', 'Org2']); + }); + + it('should format single organization as string', () => { + const singleContact = { + ...contact, + organizations: [{ name: 'SingleOrg', title: 'Dev', department: 'Dep' }], + } as GoogleContact; + const result = formatter.generateFrontmatter(singleContact, 'contact_'); + expect(result.contact_organization).toEqual('SingleOrg'); + }); + + it('should format titles as an array', () => { + const result = formatter.generateFrontmatter(contact, 'contact_'); + expect(result.contact_jobtitle).toEqual(['Developer', 'Manager']); + }); + + it('should format single title as string', () => { + const singleContact = { + ...contact, + organizations: [{ name: 'One', title: 'SingleTitle', department: 'Dep' }], + } as GoogleContact; + const result = formatter.generateFrontmatter(singleContact, 'contact_'); + expect(result.contact_jobtitle).toEqual('SingleTitle'); + }); + + it('should format departments as an array', () => { + const result = formatter.generateFrontmatter(contact, 'contact_'); + expect(result.contact_department).toEqual(['Engineering', 'Management']); + }); + + it('should format single department as string', () => { + const singleContact = { + ...contact, + organizations: [ + { name: 'One', title: 'Title', department: 'SingleDept' }, + ], + } as GoogleContact; + const result = formatter.generateFrontmatter(singleContact, 'contact_'); + expect(result.contact_department).toEqual('SingleDept'); + }); + + it('should format relations as an array', () => { + const result = formatter.generateFrontmatter(contact, 'contact_'); + expect(result.contact_relations).toEqual(['Jane (spouse)', 'Bob (friend)']); + }); + + it('should format single relation as string', () => { + const singleContact = { + ...contact, + relations: [{ person: 'SingleRel', type: 'partner' }], + } as GoogleContact; + const result = formatter.generateFrontmatter(singleContact, 'contact_'); + expect(result.contact_relations).toEqual('SingleRel (partner)'); + }); +}); diff --git a/src/core/Formatter.ts b/src/core/Formatter.ts index dcee6a7..d1de5e9 100644 --- a/src/core/Formatter.ts +++ b/src/core/Formatter.ts @@ -1,6 +1,7 @@ import { GoogleContact } from 'src/types/Contact'; import { FieldAdapter, KeyNamingStrategy } from './interfaces'; import { DefaultNamingStrategy } from './strategies/DefaultNamingStrategy'; +import { ArrayNamingStrategy } from './strategies/ArrayNamingStrategy'; import { NameAdapter } from './adapters/NameAdapter'; import { FormattedNameAdapter } from './adapters/FormattedNameAdapter'; import { EmailAdapter } from './adapters/EmailAdapter'; @@ -44,7 +45,9 @@ export class Formatter { ...context, namingStrategy: isVcfStrategy ? NamingStrategy.VCF - : NamingStrategy.Default, + : this.strategy instanceof ArrayNamingStrategy + ? NamingStrategy.Array + : NamingStrategy.Default, }; for (const [fieldId, adapter] of Object.entries(this.adapters)) { @@ -85,6 +88,8 @@ export function createDefaultFormatter( if (strategyType === NamingStrategy.VCF) { strategy = new VcfNamingStrategy(); + } else if (strategyType === NamingStrategy.Array) { + strategy = new ArrayNamingStrategy(); } else { strategy = new DefaultNamingStrategy(); } diff --git a/src/core/adapters/DepartmentAdapter.ts b/src/core/adapters/DepartmentAdapter.ts index a792888..fb76879 100644 --- a/src/core/adapters/DepartmentAdapter.ts +++ b/src/core/adapters/DepartmentAdapter.ts @@ -1,10 +1,32 @@ import { FieldAdapter, ExtractionResult } from '../interfaces'; import { GoogleContact } from '../../types/Contact'; +import { NamingStrategy } from 'src/types/Settings'; export class DepartmentAdapter implements FieldAdapter { - extract(contact: GoogleContact): ExtractionResult[] { - return (contact.organizations ?? []) + extract( + contact: GoogleContact, + context?: Record + ): ExtractionResult[] { + const departments = (contact.organizations ?? []) .filter((item) => item.department) - .map((item) => ({ value: item.department })); + .map((item) => item.department); + + if (departments.length === 0) { + return []; + } + + if (context?.namingStrategy === NamingStrategy.Array) { + const first = departments[0]; + return [ + { + value: + departments.length === 1 && first !== undefined + ? first + : departments, + }, + ]; + } + + return departments.map((value) => ({ value })); } } diff --git a/src/core/adapters/EmailAdapter.ts b/src/core/adapters/EmailAdapter.ts index 91f9d60..6259a7c 100644 --- a/src/core/adapters/EmailAdapter.ts +++ b/src/core/adapters/EmailAdapter.ts @@ -1,10 +1,27 @@ import { FieldAdapter, ExtractionResult } from '../interfaces'; import { GoogleContact } from '../../types/Contact'; +import { NamingStrategy } from 'src/types/Settings'; export class EmailAdapter implements FieldAdapter { - extract(contact: GoogleContact): ExtractionResult[] { - return (contact.emailAddresses ?? []) + extract( + contact: GoogleContact, + context?: Record + ): ExtractionResult[] { + const emails = (contact.emailAddresses ?? []) .filter((item) => item.value) - .map((item) => ({ value: item.value })); + .map((item) => item.value); + + if (emails.length === 0) { + return []; + } + + if (context?.namingStrategy === NamingStrategy.Array) { + const first = emails[0]; + return [ + { value: emails.length === 1 && first !== undefined ? first : emails }, + ]; + } + + return emails.map((value) => ({ value })); } } diff --git a/src/core/adapters/JobTitleAdapter.ts b/src/core/adapters/JobTitleAdapter.ts index 67a5cd8..1583927 100644 --- a/src/core/adapters/JobTitleAdapter.ts +++ b/src/core/adapters/JobTitleAdapter.ts @@ -1,10 +1,27 @@ import { FieldAdapter, ExtractionResult } from '../interfaces'; import { GoogleContact } from '../../types/Contact'; +import { NamingStrategy } from 'src/types/Settings'; export class JobTitleAdapter implements FieldAdapter { - extract(contact: GoogleContact): ExtractionResult[] { - return (contact.organizations ?? []) + extract( + contact: GoogleContact, + context?: Record + ): ExtractionResult[] { + const titles = (contact.organizations ?? []) .filter((item) => item.title) - .map((item) => ({ value: item.title })); + .map((item) => item.title); + + if (titles.length === 0) { + return []; + } + + if (context?.namingStrategy === NamingStrategy.Array) { + const first = titles[0]; + return [ + { value: titles.length === 1 && first !== undefined ? first : titles }, + ]; + } + + return titles.map((value) => ({ value })); } } diff --git a/src/core/adapters/OrganizationAdapter.ts b/src/core/adapters/OrganizationAdapter.ts index 09a0ad8..9f84e29 100644 --- a/src/core/adapters/OrganizationAdapter.ts +++ b/src/core/adapters/OrganizationAdapter.ts @@ -9,12 +9,31 @@ export class OrganizationAdapter implements FieldAdapter { ): ExtractionResult[] { const organizationAsLink = context?.organizationAsLink as boolean; const isVcfStrategy = context?.namingStrategy === NamingStrategy.VCF; - return (contact.organizations ?? []) + + const organizations = (contact.organizations ?? []) .map((org) => org.name) .filter((name) => !!name) - .map((name) => ({ + .map((name) => { // For VCF strategy, don't use wiki links even if organizationAsLink is true - value: organizationAsLink && !isVcfStrategy ? `[[${name}]]` : name, - })); + return organizationAsLink && !isVcfStrategy ? `[[${name}]]` : name; + }); + + if (organizations.length === 0) { + return []; + } + + if (context?.namingStrategy === NamingStrategy.Array) { + const first = organizations[0]; + return [ + { + value: + organizations.length === 1 && first !== undefined + ? first + : organizations, + }, + ]; + } + + return organizations.map((value) => ({ value })); } } diff --git a/src/core/adapters/PhoneAdapter.ts b/src/core/adapters/PhoneAdapter.ts index fecb9d4..1695c46 100644 --- a/src/core/adapters/PhoneAdapter.ts +++ b/src/core/adapters/PhoneAdapter.ts @@ -1,10 +1,27 @@ import { FieldAdapter, ExtractionResult } from '../interfaces'; import { GoogleContact } from '../../types/Contact'; +import { NamingStrategy } from 'src/types/Settings'; export class PhoneAdapter implements FieldAdapter { - extract(contact: GoogleContact): ExtractionResult[] { - return (contact.phoneNumbers ?? []) + extract( + contact: GoogleContact, + context?: Record + ): ExtractionResult[] { + const phones = (contact.phoneNumbers ?? []) .filter((item) => item.value) - .map((item) => ({ value: item.value })); + .map((item) => item.value); + + if (phones.length === 0) { + return []; + } + + if (context?.namingStrategy === NamingStrategy.Array) { + const first = phones[0]; + return [ + { value: phones.length === 1 && first !== undefined ? first : phones }, + ]; + } + + return phones.map((value) => ({ value })); } } diff --git a/src/core/adapters/RelationsAdapter.ts b/src/core/adapters/RelationsAdapter.ts index b48322a..c1718b7 100644 --- a/src/core/adapters/RelationsAdapter.ts +++ b/src/core/adapters/RelationsAdapter.ts @@ -9,14 +9,31 @@ export class RelationsAdapter implements FieldAdapter { ): ExtractionResult[] { const relationsAsLink = context?.relationsAsLink as boolean; const isVcfStrategy = context?.namingStrategy === NamingStrategy.VCF; - return (contact.relations ?? []) + + // First map to strings as per current logic + const relations = (contact.relations ?? []) .filter((relation) => !!relation.person) - .map((relation) => ({ + .map((relation) => { // For VCF strategy, don't use wiki links even if organizationsAsLink is true - value: - relationsAsLink && !isVcfStrategy - ? `[[${relation.person}|${relation.person} (${relation.type})]]` - : `${relation.person} (${relation.type})`, - })); + return relationsAsLink && !isVcfStrategy + ? `[[${relation.person}|${relation.person} (${relation.type})]]` + : `${relation.person} (${relation.type})`; + }); + + if (relations.length === 0) { + return []; + } + + if (context?.namingStrategy === NamingStrategy.Array) { + const first = relations[0]; + return [ + { + value: + relations.length === 1 && first !== undefined ? first : relations, + }, + ]; + } + + return relations.map((value) => ({ value })); } } diff --git a/src/core/strategies/ArrayNamingStrategy.ts b/src/core/strategies/ArrayNamingStrategy.ts new file mode 100644 index 0000000..ecaf3f5 --- /dev/null +++ b/src/core/strategies/ArrayNamingStrategy.ts @@ -0,0 +1,7 @@ +import { DefaultNamingStrategy } from './DefaultNamingStrategy'; + +export class ArrayNamingStrategy extends DefaultNamingStrategy { + // Inherits generateKey from DefaultNamingStrategy + // The magic happens in the adapters which will return a single array value + // when they detect this strategy in the context. +} diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 64b7764..ec1d227 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -42,6 +42,7 @@ export const ru = { 'Стратегия генерации ключей frontmatter из данных контакта', Default: 'Стандартная', 'VCF (vCard)': 'VCF (vCard)', + Array: 'Массив', 'Organization as link': 'Организация как ссылка', 'Organization name will be stored as a obsidian link [[...]] instead of plain text': 'Название организации будет сохранено как ссылка Obsidian [[...]] вместо обычного текста (игнорируется в стратегии VCF)', @@ -138,6 +139,7 @@ export const lv = { 'Stratēģija frontmatter atslēgu ģenerēšanai no kontakta datiem', Default: 'Noklusējuma', 'VCF (vCard)': 'VCF (vCard)', + Array: 'Masīvs', 'Organization as link': 'Organizācija kā saite', 'Organization name will be stored as a obsidian link [[...]] instead of plain text': 'Organizācijas nosaukums tiks saglabāts kā Obsidian saite [[...]], nevis parasts teksts (tiek ignorēts VCF stratēģijā)', @@ -233,6 +235,7 @@ export const en = { 'Strategy to generate frontmatter keys from contact data', Default: 'Default', 'VCF (vCard)': 'VCF (vCard)', + Array: 'Array', 'Organization as link': 'Organization as link', 'Organization name will be stored as a obsidian link [[...]] instead of plain text': 'Organization name will be stored as a obsidian link [[...]] instead of plain text (ignored in VCF strategy)', diff --git a/src/plugin/settings.ts b/src/plugin/settings.ts index 411c3e9..6fff275 100644 --- a/src/plugin/settings.ts +++ b/src/plugin/settings.ts @@ -109,6 +109,7 @@ export class ContactSyncSettingTab extends PluginSettingTab { dropdown .addOption('Default', t('Default')) .addOption('VCF', t('VCF (vCard)')) + .addOption('Array', t('Array')) .setValue(this.plugin.settings.namingStrategy) .onChange(async (value) => { this.plugin.settings.namingStrategy = value as NamingStrategy; diff --git a/src/types/Settings.ts b/src/types/Settings.ts index ba7a685..bb39947 100644 --- a/src/types/Settings.ts +++ b/src/types/Settings.ts @@ -63,4 +63,5 @@ export interface ContactSyncSettings { export enum NamingStrategy { Default = 'Default', VCF = 'VCF', + Array = 'Array', }