From 9fda442a9e0c5199a5e7c88f476e0c180640483b Mon Sep 17 00:00:00 2001 From: Roland Broekema Date: Tue, 1 Jul 2025 18:58:55 +0200 Subject: [PATCH] Refs #8: after a couple hours more work we can now communicate with the carddav server to get metadata push and pull --- prompting.md | 1 + src/contacts/contactDataKeys.ts | 3 +- src/contacts/vcard/toString.ts | 6 +- src/insights/processors/UidProcessor.tsx | 13 +- src/sync/adapters/adapter.d.ts | 14 +- src/sync/adapters/adapterInterface.ts | 10 +- src/sync/adapters/carddavGeneric.ts | 227 ++++++++++++++++++---- src/sync/adapters/index.ts | 6 + src/sync/index.ts | 16 +- src/sync/singlePull.ts | 26 +++ src/sync/singlePush.ts | 24 +++ src/ui/sidebar/components/ContactView.tsx | 15 ++ src/util/platformHttpClient.ts | 2 +- src/util/vcard.ts | 36 ++++ 14 files changed, 333 insertions(+), 66 deletions(-) create mode 100644 src/sync/adapters/index.ts create mode 100644 src/sync/singlePull.ts create mode 100644 src/sync/singlePush.ts create mode 100644 src/util/vcard.ts diff --git a/prompting.md b/prompting.md index 3ec77f1..4b136fd 100644 --- a/prompting.md +++ b/prompting.md @@ -28,3 +28,4 @@ While the book doesn’t 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. + diff --git a/src/contacts/contactDataKeys.ts b/src/contacts/contactDataKeys.ts index 0201bef..7c75512 100644 --- a/src/contacts/contactDataKeys.ts +++ b/src/contacts/contactDataKeys.ts @@ -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 }; } diff --git a/src/contacts/vcard/toString.ts b/src/contacts/vcard/toString.ts index bb644a7..26b8d8f 100644 --- a/src/contacts/vcard/toString.ts +++ b/src/contacts/vcard/toString.ts @@ -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); @@ -62,6 +60,10 @@ function generateVCard(file: TFile): string { singleLineFields.push([key, value]); } }); + + if (!singleLineFields.some(([key]) => key === 'FN')) { + singleLineFields.push(['FN', file.basename]); + } const structuredLines = renderStructuredLines(structuredFields); const singleLines = singleLineFields.map(renderSingleKey); diff --git a/src/insights/processors/UidProcessor.tsx b/src/insights/processors/UidProcessor.tsx index 7ba06ca..16920a7 100644 --- a/src/insights/processors/UidProcessor.tsx +++ b/src/insights/processors/UidProcessor.tsx @@ -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 ( diff --git a/src/sync/adapters/adapter.d.ts b/src/sync/adapters/adapter.d.ts index e7e55eb..0a4d283 100644 --- a/src/sync/adapters/adapter.d.ts +++ b/src/sync/adapters/adapter.d.ts @@ -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; } diff --git a/src/sync/adapters/adapterInterface.ts b/src/sync/adapters/adapterInterface.ts index 9dade60..37191c7 100644 --- a/src/sync/adapters/adapterInterface.ts +++ b/src/sync/adapters/adapterInterface.ts @@ -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; + pull(href: string): Promise; /** * Get a list of all vCard metadata (UID, name, lastModified, etc). * @returns Promise resolving to an array of vCard metadata objects */ - getList(): Promise; + getMetaList(): Promise; + + /** + * Get a vCard metadata (UID, name, lastModified, etc). + * @returns Promise resolving to an array of vCard metadata objects + */ + getMetaByUid(uid: string): Promise; /** * Check if the remote party is reachable with current settings. diff --git a/src/sync/adapters/carddavGeneric.ts b/src/sync/adapters/carddavGeneric.ts index 7438c7a..2d871a9 100644 --- a/src/sync/adapters/carddavGeneric.ts +++ b/src/sync/adapters/carddavGeneric.ts @@ -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 =>{ - return fetch(settings.CardDAV.addressBookUrl, { - ...options, - headers: { - ...options.headers, - ['Authorization']: this.getAuthHeader() - } - }); - } - - const doPush = async (options: RequestInit, vcard: VCardRaw): Promise => { - 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 => { @@ -54,24 +33,194 @@ export function carddavGenericAdapter(): AdapterInterface { }); } - const getList = async (): Promise => { - 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 => { + const settings = getSettings(); + const body = ` + + + + + + + + + + + + + ${uid} + + +`; + + 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 => { - return Promise.resolve(undefined); + + const getMetaList = async (): Promise => { + const settings = getSettings(); + const body = ` + + + + + + + + + + +`; + + 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 => { + 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 => { - 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 }; } - diff --git a/src/sync/adapters/index.ts b/src/sync/adapters/index.ts new file mode 100644 index 0000000..53ae93e --- /dev/null +++ b/src/sync/adapters/index.ts @@ -0,0 +1,6 @@ +import { carddavGenericAdapter } from "src/sync/adapters/carddavGeneric"; + +export const adapters = { + None: undefined, + CardDAV: carddavGenericAdapter() +} diff --git a/src/sync/index.ts b/src/sync/index.ts index aa71576..fc75209 100644 --- a/src/sync/index.ts +++ b/src/sync/index.ts @@ -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:{}, }; diff --git a/src/sync/singlePull.ts b/src/sync/singlePull.ts new file mode 100644 index 0000000..565c307 --- /dev/null +++ b/src/sync/singlePull.ts @@ -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 { + const setting = getSettings(); + const addapter = adapters[setting.syncSelected]; + if(addapter) { + const res = addapter.getMetaByUid(uid); + } + +} + +export async function singlePull(href: string): Promise { + const setting = getSettings(); + const addapter = adapters[setting.syncSelected]; + + if(addapter) { + const res = addapter.pull(href); + } + +} diff --git a/src/sync/singlePush.ts b/src/sync/singlePush.ts new file mode 100644 index 0000000..9437fc0 --- /dev/null +++ b/src/sync/singlePush.ts @@ -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 + }) + } + + +} diff --git a/src/ui/sidebar/components/ContactView.tsx b/src/ui/sidebar/components/ContactView.tsx index 422c210..c7ed9da 100644 --- a/src/ui/sidebar/components/ContactView.tsx +++ b/src/ui/sidebar/components/ContactView.tsx @@ -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) => { }} > +
(buttons.current[2] = element)} + onClick={(event) => { + event.stopPropagation(); + sync.do(); + // sync.singlePush(contact.file); + }} + > +
diff --git a/src/util/platformHttpClient.ts b/src/util/platformHttpClient.ts index 6ef91da..2f5a26b 100644 --- a/src/util/platformHttpClient.ts +++ b/src/util/platformHttpClient.ts @@ -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; body?: any; } diff --git a/src/util/vcard.ts b/src/util/vcard.ts new file mode 100644 index 0000000..9ed6a32 --- /dev/null +++ b/src/util/vcard.ts @@ -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); + }); +}