Array in property values #36 (#37)

This commit is contained in:
aleksejs1 2026-01-27 06:05:18 +02:00 committed by GitHub
parent 196ecf5221
commit 2718178d8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 263 additions and 24 deletions

View file

@ -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

View file

@ -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)');
});
});

View file

@ -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();
}

View file

@ -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<string, unknown>
): 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 }));
}
}

View file

@ -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<string, unknown>
): 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 }));
}
}

View file

@ -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<string, unknown>
): 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 }));
}
}

View file

@ -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 }));
}
}

View file

@ -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<string, unknown>
): 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 }));
}
}

View file

@ -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 }));
}
}

View file

@ -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.
}

View file

@ -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)',

View file

@ -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;

View file

@ -63,4 +63,5 @@ export interface ContactSyncSettings {
export enum NamingStrategy {
Default = 'Default',
VCF = 'VCF',
Array = 'Array',
}