Refs #8: after a couple hours more work we can now communicate with the carddav server to get metadata push and pull

This commit is contained in:
Roland Broekema 2025-07-01 18:58:55 +02:00
parent 0810c8af51
commit 9fda442a9e
14 changed files with 333 additions and 66 deletions

View file

@ -28,3 +28,4 @@ While the book doesnt define a testing method, it strongly influences how we
- **Example:**
- Move validation, data transformation, and API interaction logic from UI components to `settings.ts` or a dedicated service module.
- UI components should only call functions from these modules and display results.

View file

@ -52,7 +52,7 @@ function extractSubkey(input: string): { main: string; subkey?: string } {
* Otherwise, it assumes the content is a type.
*/
function parseBracketContent(content: string): { index?: string; type?: string } {
if (content.includes(':')) {
if (content.includes(':')) {
const [index, type] = content.split(':');
return { index, type };
}
@ -81,7 +81,6 @@ function parseKeyPart(main: string): { key: string; index?: string; type?: strin
// Extract and parse the content within the brackets.
const bracketContent = main.substring(openBracketIndex + 1, closeBracketIndex);
const { index, type } = parseBracketContent(bracketContent);
return { key, index, type };
}

View file

@ -51,8 +51,6 @@ function generateVCard(file: TFile): string {
const singleLineFields: Array<[string, string]> = [];
const structuredFields: Array<[string, string]> = [];
singleLineFields.push(['FN', file.basename]);
entries.forEach(([key, value]) => {
const keyObj = parseKey(key);
@ -63,6 +61,10 @@ function generateVCard(file: TFile): string {
}
});
if (!singleLineFields.some(([key]) => key === 'FN')) {
singleLineFields.push(['FN', file.basename]);
}
const structuredLines = renderStructuredLines(structuredFields);
const singleLines = singleLineFields.map(renderSingleKey);
const lines = structuredLines.concat(singleLines);

View file

@ -2,18 +2,9 @@ import * as React from "react";
import { Contact, updateFrontMatterValue } from "src/contacts";
import { getSettings } from "src/context/sharedSettingsContext";
import { InsightProcessor, InsightQueItem, RunType } from "src/insights/insight.d";
import { generateUUID } from "src/util/vcard";
// Zero dependency uuid generator as its not used for millions of records
const generateUUID = (): string => {
const timestamp = Date.now().toString(16).padStart(12, '0');
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
}).replace(/^(.{24})/, (_, p1) => {
return timestamp + p1.slice(timestamp.length);
});
}
const renderGroup = (queItems: InsightQueItem[]):JSX.Element => {
return (

View file

@ -1,12 +1,12 @@
export interface VCardRaw {
uid: string;
raw: string; // The raw VCF data
// Optionally add parsed fields if desired (name, email, etc)
uid: string | undefined;
raw: string; // The raw VCF data
}
export interface VCardMeta {
uid: string;
name?: string;
lastModified: string; // ISO string, always present
// Optionally more metadata (e.g., email, phone, photo presence, etc)
href: string;
etag: string;
lastModified: Date;
uid: string | undefined;
fn: string | undefined;
}

View file

@ -15,13 +15,19 @@ export interface AdapterInterface {
* @param uid The unique identifier of the vCard to pull
* @returns Promise resolving to the vCard (or null if not found)
*/
pull(uid: string): Promise<VCardRaw | undefined>;
pull(href: string): Promise<VCardRaw | undefined>;
/**
* Get a list of all vCard metadata (UID, name, lastModified, etc).
* @returns Promise resolving to an array of vCard metadata objects
*/
getList(): Promise<VCardMeta[]>;
getMetaList(): Promise<VCardMeta[]>;
/**
* Get a vCard metadata (UID, name, lastModified, etc).
* @returns Promise resolving to an array of vCard metadata objects
*/
getMetaByUid(uid: string): Promise<VCardMeta|undefined>;
/**
* Check if the remote party is reachable with current settings.

View file

@ -1,43 +1,22 @@
import { getSettings, onSettingsChange } from "src/context/sharedSettingsContext";
import { getSettings } from "src/context/sharedSettingsContext";
import { ContactsPluginSettings } from "src/settings/settings";
import { VCardMeta, VCardRaw } from "src/sync/adapters/adapter";
import { AdapterInterface } from "src/sync/adapters/adapterInterface";
import { getList } from "src/sync/singlePull";
import { CarddavSettingsInterface } from "src/ui/settings/components/carddavSettings";
import { AppHttpResponse, PlatformHttpClient } from "src/util/platformHttpClient";
import { fnOutOfString, uidOutOfString } from "src/util/vcard";
export function carddavGenericAdapter(): AdapterInterface {
let settings = getSettings();
onSettingsChange(()=> {
settings = getSettings();
});
const getAuthHeader = () => {
return 'Bearer ' + this.bearerToken;
}
const doFetch = async (options: RequestInit): Promise<Response> =>{
return fetch(settings.CardDAV.addressBookUrl, {
...options,
headers: {
...options.headers,
['Authorization']: this.getAuthHeader()
}
});
}
const doPush = async (options: RequestInit, vcard: VCardRaw): Promise<void> => {
const vcfUrl = settings.CardDAV.addressBookUrl + `${vcard.uid}.vcf`;
const res = await fetch(vcfUrl, {
method: 'PUT',
body: vcard.raw,
headers: {
...options.headers,
['Authorization']: this.getAuthHeader(),
['Content-Type']: 'text/vcard; charset=utf-8'
}
});
if (!res.ok) throw new Error(`Push failed: ${res.statusText}`);
const getAuthHeader = (settings:ContactsPluginSettings) => {
if (settings.CardDAV.authType === 'apikey') {
return `Bearer ${settings.CardDAV.authKey}`;
} else {
return `Basic ${settings.CardDAV.authKey}`;
}
}
const checkConnectivity = async (settings: CarddavSettingsInterface): Promise<AppHttpResponse> => {
@ -54,24 +33,194 @@ export function carddavGenericAdapter(): AdapterInterface {
});
}
const getList = async (): Promise<VCardMeta[]> => {
return Promise.resolve([]);
const isVcfNode = (response: Element): boolean => {
const href = response.querySelector('href')?.textContent;
const successPropstat = Array.from(response.querySelectorAll('propstat')).find(
propstat => propstat.querySelector('status')?.textContent?.includes('200 OK')
);
if (!successPropstat || !href || !href.endsWith('.vcf')) {
return false;
}
return true;
};
const getMetaByUid = async (uid: string): Promise<VCardMeta|undefined> => {
const settings = getSettings();
const body = `<?xml version="1.0" encoding="UTF-8"?>
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop>
<D:getetag />
<D:getlastmodified />
<D:resourcetype />
<C:address-data>
<C:prop name="UID"/>
<C:prop name="FN"/>
</C:address-data>
</D:prop>
<C:filter>
<C:prop-filter name="UID">
<C:text-match match-type="contains">${uid}</C:text-match>
</C:prop-filter>
</C:filter>
</C:addressbook-query>`;
const res = await PlatformHttpClient.request({
url: settings.CardDAV.addressBookUrl,
method: 'REPORT',
headers: {
'Authorization': getAuthHeader(settings),
'Content-Type': 'application/xml',
'Depth': '1'
},
body
});
const parser = new DOMParser();
const xml = parser.parseFromString(res.data, 'application/xml');
const responses = xml.querySelectorAll('response');
if(!responses || responses.length !== 1) {
return;
}
const response = responses[0];
const href = response.querySelector('href')?.textContent || '';
const etag = response.querySelector('getetag')?.textContent || '';
const lastModified = response.querySelector('getlastmodified')?.textContent || '';
const adressData = response.querySelector('address-data')?.textContent || '';
if (!href) {
return;
}
console.log({
href,
etag: etag?.replace(/"/g, '') || '', // Remove quotes from etag
lastModified: lastModified ? new Date(lastModified) : new Date(),
uid: uidOutOfString(adressData),
fn: fnOutOfString(adressData)
});
return {
href,
etag: etag?.replace(/"/g, '') || '', // Remove quotes from etag
lastModified: lastModified ? new Date(lastModified) : new Date(),
uid: uidOutOfString(adressData),
fn: fnOutOfString(adressData)
}
}
const pull = async (uid: string): Promise<VCardRaw | undefined> => {
return Promise.resolve(undefined);
const getMetaList = async (): Promise<VCardMeta[]> => {
const settings = getSettings();
const body = `<?xml version="1.0" encoding="UTF-8"?>
<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop>
<D:getetag />
<D:getlastmodified />
<D:resourcetype />
<C:address-data>
<C:prop name="UID"/>
<C:prop name="FN"/>
</C:address-data>
</D:prop>
</C:addressbook-query>`;
const res = await PlatformHttpClient.request({
url: settings.CardDAV.addressBookUrl ,
method: 'REPORT',
headers: {
'Authorization': getAuthHeader(settings),
'Content-Type': 'application/xml',
'Depth': '1'
},
body
});
const parser = new DOMParser();
const xml = parser.parseFromString(res.data, 'application/xml');
const responses = xml.querySelectorAll('response');
const vcardMetas: VCardMeta[] = [];
responses.forEach(response => {
if(!response) {
return;
}
if (!isVcfNode(response)) {
return;
}
const href = response.querySelector('href')?.textContent;
const etag = response.querySelector('getetag')?.textContent;
const lastModified = response.querySelector('getlastmodified')?.textContent;
const adressData = response.querySelector('address-data')?.textContent || '';
if (!href) {
return;
}
vcardMetas.push({
href,
etag: etag?.replace(/"/g, '') || '', // Remove quotes from etag
lastModified: lastModified ? new Date(lastModified) : new Date(),
uid: uidOutOfString(adressData),
fn: fnOutOfString(adressData)
});
});
return vcardMetas;
}
const pull = async (href: string): Promise<VCardRaw | undefined> => {
const settings = getSettings();
const vcfUrl = new URL(href, settings.CardDAV.addressBookUrl).toString();
console.log('vcfUrl', vcfUrl);
const res = await PlatformHttpClient.request({
url: vcfUrl,
method: 'GET',
headers: {
Authorization: getAuthHeader(settings)
}
});
console.log({
uid: uidOutOfString(res.data),
raw: res.data,
});
return {
uid: uidOutOfString(res.data),
raw: res.data,
}
}
const push = async (vcard: VCardRaw): Promise<void> => {
return Promise.resolve(undefined);
const settings = getSettings();
const vcfUrl = settings.CardDAV.addressBookUrl + `/${vcard.uid}.vcf`;
const res = await PlatformHttpClient.request({
url: vcfUrl,
method: 'PUT',
body: vcard.raw,
headers: {
['Authorization']: getAuthHeader(settings),
['Content-Type']: 'text/vcard; charset=utf-8'
}
});
console.log('done push', res);
}
return {
checkConnectivity,
getList,
getMetaByUid,
getMetaList,
pull,
push,
push
};
}

View file

@ -0,0 +1,6 @@
import { carddavGenericAdapter } from "src/sync/adapters/carddavGeneric";
export const adapters = {
None: undefined,
CardDAV: carddavGenericAdapter()
}

View file

@ -1,5 +1,17 @@
import { getList, getMetaByUID, singlePull, } from "src/sync/singlePull";
import { singlePush } from "src/sync/singlePush";
export const sync = {
singlePull,
singlePush,
getList,
do: async () => {
const list = await getList();
if (list && list[0]) {
getMetaByUID('019730a76c153-4458-a488-2f227fab60e7')
// singlePull(list[0])
}
},
running: {},
enabled:{},
};

26
src/sync/singlePull.ts Normal file
View file

@ -0,0 +1,26 @@
import { getSettings } from "src/context/sharedSettingsContext";
import { adapters } from "src/sync/adapters";
export async function getList() {
const setting = getSettings();
return adapters[setting.syncSelected]?.getMetaList();
}
export async function getMetaByUID(uid: string): Promise<void> {
const setting = getSettings();
const addapter = adapters[setting.syncSelected];
if(addapter) {
const res = addapter.getMetaByUid(uid);
}
}
export async function singlePull(href: string): Promise<void> {
const setting = getSettings();
const addapter = adapters[setting.syncSelected];
if(addapter) {
const res = addapter.pull(href);
}
}

24
src/sync/singlePush.ts Normal file
View file

@ -0,0 +1,24 @@
import { TFile } from "obsidian";
import { getFrontmatterFromFiles } from "src/contacts";
import { vcard } from "src/contacts/vcard";
import { getSettings } from "src/context/sharedSettingsContext";
import { adapters } from "src/sync/adapters";
export async function singlePush(file: TFile) {
const setting = getSettings();
const frontmatter = (await getFrontmatterFromFiles([file]))[0]
const addapter = adapters[setting.syncSelected];
if(addapter) {
const result = await vcard.toString([file]);
if (result.errors.length > 0) {
return;
}
await addapter.push({
uid: frontmatter.data['UID'].split(':').pop(),
raw: result.vcards
})
}
}

View file

@ -3,6 +3,7 @@ import * as React from "react";
import { Contact, parseKey } from "src/contacts";
import { getApp } from "src/context/sharedAppContext";
import { fileId, openFile } from "src/file/file";
import { sync } from "src/sync";
import Avatar from "src/ui/sidebar/components/Avatar";
import { CopyableItem } from "src/ui/sidebar/components/CopyableItem";
@ -243,6 +244,20 @@ export const ContactView = (props: ContactProps) => {
}}
>
</div>
<div
data-icon="refresh-ccw-dot"
className={
"clickable-icon nav-action-button "
}
aria-label="Sync"
ref={(element) => (buttons.current[2] = element)}
onClick={(event) => {
event.stopPropagation();
sync.do();
// sync.singlePush(contact.file);
}}
>
</div>
</div>
</div>
</div>

View file

@ -1,6 +1,6 @@
export interface AppHttpRequest {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS';
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS' | 'PROPFIND' | 'REPORT';
headers?: Record<string, string>;
body?: any;
}

36
src/util/vcard.ts Normal file
View file

@ -0,0 +1,36 @@
export function uidOutOfString(messyString: string):string|undefined {
const unfolded = messyString.replace(/\r\n[ \t]/g, '');
const match = unfolded.match(/^uid:.*$/gim);
let uid;
if(match) {
const uidLine = match[0]
const parts = uidLine.split(':');
uid = parts.length > 1 ? parts.at(-1)!.trim() : '';
}
return uid
}
export function fnOutOfString(messyString: string):string|undefined {
const unfolded = messyString.replace(/\r\n[ \t]/g, '');
const match = unfolded.match(/^fn:.*$/gim);
let fn;
if(match) {
const uidLine = match[0]
const parts = uidLine.split(':');
fn = parts.length > 1 ? parts.at(-1)!.trim() : '';
}
return fn
}
// Zero dependency uuid generator as its not used for millions of records
export function generateUUID(): string {
const timestamp = Date.now().toString(16).padStart(12, '0');
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
}).replace(/^(.{24})/, (_, p1) => {
return timestamp + p1.slice(timestamp.length);
});
}