Refs #8: we have a settings page that can check the connection and save the settings if ok

This commit is contained in:
Roland Broekema 2025-06-26 14:36:59 +02:00
parent 12aa4453ba
commit 0810c8af51
14 changed files with 466 additions and 50 deletions

View file

@ -351,6 +351,19 @@ npm run test:coverage
For a breakdown of our testing approach, structure, and goals, see:
👉 [our testing strategy](assets/docs/testing-strategy.md)
---
## 🌟 Support & Get Involved
We rely on our community to keep this project free and moving forward. If you believe in what were building, nows the perfect time to step out of the shadows and help shape the future of this plugin!
Spread the word, connect us with sponsors, or simply share your enthusiasm, your support matters.
Ready to make a difference? [Sign up here.](https://tally.so/r/mR6rKj)
---
<!-- TOC --><a name="-acknowledgements"></a>
@ -358,5 +371,5 @@ For a breakdown of our testing approach, structure, and goals, see:
This plugin started as a fork of **Vadim Beskrovnovs Contacts plugin**. While the codebase has since evolved significantly, his original work laid the foundation. Immense thanks to Vadim for the early inspiration and groundwork.
---
---

30
prompting.md Normal file
View file

@ -0,0 +1,30 @@
We are building on a obsidian plugin that can manage, organize, and interact with your contacts inside Obsidian. Import, export, and structure vCard (VCF) files seamlessly while keeping all contact details accessible in your knowledge base. Includes click-to-call, right-click copy, structured metadata, and more!
Our approach is shaped by ideas from A Philosophy of Software Design by John Ousterhout. A recommended read if you're into practical thinking about clarity, change, and modularity.
While the book doesnt define a testing method, it strongly influences how we code. We care about what modules do. not how they do it. Using deep interfaces. This makes tests more stable and more useful during redesigns.
1) Separate ui tsx from core modules like parsing, adapters and utillities.
2) separate obsidian interfaces where we can so that testing becomes easier.
3) Avoid the use of fetch to interact with api's as this results in cors errors
4) Desktop in obsidion is built with Electron and the app is based on Capacitor
## Code Structure Suggestions
- **Move business logic out of UI components:**
Keep your `.tsx` files focused on rendering and user interaction. Avoid nested ifs and complex logic in UI code.
- **Centralize business rules and configuration:**
Place business logic, configuration, and state management in core modules or in `settings.ts`. This keeps logic reusable and easier to test.
- **Use clear module boundaries:**
Parsing, adapters, and utilities should be in their own files. UI should call into these modules, not contain their logic.
- **Benefits:**
- Easier to test business logic without UI dependencies
- Cleaner, more maintainable UI code
- Simpler redesigns and refactoring
- **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

@ -44,7 +44,7 @@ export const UidProcessor: InsightProcessor = {
settingDefaultValue: true,
async process(contact:Contact): Promise<InsightQueItem | undefined> {
const activeProcessor = getSettings()[`${this.settingPropertyName}`] as boolean;
const activeProcessor = getSettings().processors[`${this.settingPropertyName}`] as boolean;
if (!activeProcessor || contact.data['UID']) {
return Promise.resolve(undefined);
}

View file

@ -1,7 +1,8 @@
import "src/insights/insightLoading";
import { Plugin } from 'obsidian';
import { ContactsSettingTab, DEFAULT_SETTINGS } from 'src/ui/settings/settingsView';
import { DEFAULT_SETTINGS } from "src/settings/setting";
import { ContactsSettingTab } from 'src/ui/settings/settingsView';
import { ContactsView } from "src/ui/sidebar/sidebarView";
import { CONTACTS_VIEW_CONFIG } from "src/util/constants";
import myScrollTo from "src/util/myScrollTo";

23
src/settings/setting.ts Normal file
View file

@ -0,0 +1,23 @@
import { insightService } from "src/insights/insightService";
import { ContactsPluginSettings } from "src/settings/settings";
const insightsSetting = insightService.settings();
const insightsSettingDefaults = insightsSetting.reduce((acc:Record<string, string|boolean>, setting) => {
acc[setting.settingPropertyName] = setting.settingDefaultValue;
return acc;
}, {} as Record<string, string>);
export const DEFAULT_SETTINGS: ContactsPluginSettings = {
contactsFolder: '',
defaultHashtag: '',
processors: insightsSettingDefaults,
syncSelected: 'None',
CardDAV: {
addressBookUrl: '',
syncEnabled: false,
syncInterval: 900,
authKey: '',
authType: 'apikey'
}
}

View file

@ -1,7 +1,24 @@
export type AuthType = "basic" | "apikey";
export type SyncSelected = "None" | "CardDAV";
export interface ContactsPluginSettings {
contactsFolder: string;
defaultHashtag: string;
[key: string]: string|boolean;
processors: ProcessorsSettings
syncSelected: SyncSelected;
CardDAV: CardDavSyncSettings;
}
interface CardDavSyncSettings {
addressBookUrl: string;
syncEnabled: boolean;
syncInterval: number;
authKey: string;
authType: AuthType;
}
interface ProcessorsSettings {
[key: string]: string|boolean;
}

View file

@ -1,4 +1,6 @@
import { VCardMeta, VCardRaw } from "src/sync/adapters/adapter";
import { CarddavSettingsInterface } from "src/ui/settings/components/carddavSettings";
import { AppHttpResponse } from "src/util/platformHttpClient";
export interface AdapterInterface {
/**
@ -25,7 +27,7 @@ export interface AdapterInterface {
* Check if the remote party is reachable with current settings.
* @returns Promise resolving to true if connection succeeds, false otherwise
*/
checkConnectivity(): Promise<boolean>;
checkConnectivity(settings: CarddavSettingsInterface): Promise<AppHttpResponse>
}

View file

@ -1,21 +1,23 @@
import { getSettings, onSettingsChange } from "src/context/sharedSettingsContext";
import { VCardMeta, VCardRaw } from "src/sync/adapters/adapter";
import { AdapterInterface } from "src/sync/adapters/adapterInterface";
import { CarddavSettingsInterface } from "src/ui/settings/components/carddavSettings";
import { AppHttpResponse, PlatformHttpClient } from "src/util/platformHttpClient";
export class CarddavGenericAdapter implements AdapterInterface {
private bearerToken: string;
private addressBookUrl: string;
constructor() {
this.bearerToken = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiIxIiwianRpIjoiMDFkZjgwZmMwNmQ2ZTUzZTYxMjA4MGQ1MTRhMGRkYThmMGYxNzBkOGFiM2MxYmM0ZDIxM2Y4NDk0MzFjNDNmZGQxZTc4YzMxYTgxZDY2OGEiLCJpYXQiOjE3NTAzMzY3NzQuNjg4ODc2LCJuYmYiOjE3NTAzMzY3NzQuNjg4ODc4LCJleHAiOjE3ODE4NzI3NzQuNjgzMDM5LCJzdWIiOiIxIiwic2NvcGVzIjpbXX0.KwCHqO-9wBxmo86W-CLc4uEdFelVX2ZSHuG_e-6Gem3cRadw9ARP19OfusVanE9OPP88-8LCPTHM9ZAYu9sEVAC5kCEKI0HQ9AIvmLDlzgTTmmm_UZMYbOj86h2tevvxdttHlhJYbMl-9L9URy9AfHtxafB4oBiNLA9fsXWnC00cAuxNyLXVtcxedvlDemNXSRr6GZlTfrwKs0Unaf3TFVQlDznOn78lBSUkM53sxq6UP7zCNrfxIjY1lG75C0oFtEkalQXqnSu0-Cd6rPecCjK76mZoWNrcLK04ss2UtOyKWyytSwdnllF2CMXnB995xKOkmzxTWtnz7WuToHVMad7ED87QiVXVld3lJXmR-1NuAfLeWGN1AY4VUu5tq7uu38Px5XYWUi-D_Tkog3uNT9J015RnfRJQvQO6SaUFjUYRNlx2uZ78TqDwrQmruHAhmj4RxaLVKdVcNhiLGk1b-fnr1GV4ki7nrxGgdxudEGnB-A5GAPAjVyM7ML4r2wT0RR-q2QZMUi2D-vuPHM1EPL21hPIssDmrBxhaXh9NUaKk16BnzRr8RaHXWlZMtwe9V5Piy0U5QU9fIDA1aR2qBuW-37toVZDP6H6rNShe8x72c5OSUqyxuvaXDhEsbxZ4DecaP2ILFHtNG0b59eACMcW6drzW4YZx_v1LZFppc7c' ;
this.addressBookUrl = 'http://localhost:8080/dav/addressbooks/broekema41@gmail.com/contacts' ;
}
export function carddavGenericAdapter(): AdapterInterface {
private getAuthHeader() {
let settings = getSettings();
onSettingsChange(()=> {
settings = getSettings();
});
const getAuthHeader = () => {
return 'Bearer ' + this.bearerToken;
}
private async doFetch(options: RequestInit): Promise<Response> {
return fetch(this.addressBookUrl, {
const doFetch = async (options: RequestInit): Promise<Response> =>{
return fetch(settings.CardDAV.addressBookUrl, {
...options,
headers: {
...options.headers,
@ -24,8 +26,8 @@ export class CarddavGenericAdapter implements AdapterInterface {
});
}
private async doPush(options: RequestInit, vcard: VCardRaw): Promise<void> {
const vcfUrl = this.addressBookUrl + `${vcard.uid}.vcf`;
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,
@ -38,26 +40,38 @@ export class CarddavGenericAdapter implements AdapterInterface {
if (!res.ok) throw new Error(`Push failed: ${res.statusText}`);
}
async checkConnectivity(): Promise<boolean> {
try {
const res = await this.doFetch({ method: 'OPTIONS' });
return res.ok;
} catch {
return false;
const checkConnectivity = async (settings: CarddavSettingsInterface): Promise<AppHttpResponse> => {
const headers = { Authorization : ''};
if (settings.authKey) {
headers.Authorization = `Bearer ${settings.authKey}`;
} else {
headers.Authorization = 'Basic ' + btoa(settings.username + ":" + settings.password);
}
return await PlatformHttpClient.request({
url: settings.addressBookUrl,
method: 'OPTIONS',
headers,
});
}
getList(): Promise<VCardMeta[]> {
const getList = async (): Promise<VCardMeta[]> => {
return Promise.resolve([]);
}
pull(uid: string): Promise<VCardRaw | undefined> {
const pull = async (uid: string): Promise<VCardRaw | undefined> => {
return Promise.resolve(undefined);
}
push(vcard: VCardRaw): Promise<void> {
const push = async (vcard: VCardRaw): Promise<void> => {
return Promise.resolve(undefined);
}
return {
checkConnectivity,
getList,
pull,
push,
};
}

View file

@ -0,0 +1,94 @@
// In CarddavSettings.jsx:
import * as React from "react";
export interface CarddavSettingsInterface {
addressBookUrl: string;
username: string;
password: string;
authKey: string;
}
interface CarddavSettingsProps {
carddavSettings: CarddavSettingsInterface;
setCarddavSettings: (settings: CarddavSettingsInterface) => void;
}
export default function CarddavSettings({ carddavSettings, setCarddavSettings }: CarddavSettingsProps) {
return (
<>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Address Book URL</div>
<div className="setting-item-description">
URL of your CardDAV address book.
</div>
</div>
<div className="setting-item-control">
<input
className="textfield"
type="text"
placeholder="https://example.com/carddav"
value={carddavSettings.addressBookUrl}
onChange={e => setCarddavSettings({ ...carddavSettings, addressBookUrl: e.target.value })}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Username</div>
<div className="setting-item-description">
account username.
</div>
</div>
<div className="setting-item-control">
<input
className="textfield"
type="text"
placeholder="username"
value={carddavSettings.username}
onChange={e => setCarddavSettings({ ...carddavSettings, username: e.target.value })}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">Password</div>
<div className="setting-item-description">
CardDAV account password.
</div>
</div>
<div className="setting-item-control">
<input
className="textfield"
type="password"
placeholder="password"
value={carddavSettings.password}
onChange={e => setCarddavSettings({ ...carddavSettings, password: e.target.value })}
/>
</div>
</div>
<div className="setting-item">
<div className="setting-item-info">
<div className="setting-item-name">API Key</div>
<div className="setting-item-description">
API key used instead of username and password.
</div>
</div>
<div className="setting-item-control">
<input
className="textfield"
type="text"
placeholder="API key"
value={carddavSettings.authKey}
onChange={e => setCarddavSettings({ ...carddavSettings, authKey: e.target.value })}
/>
</div>
</div>
</>
);
}

View file

@ -2,22 +2,22 @@ import { Setting } from "obsidian";
import * as React from "react";
import { setSettings } from "src/context/sharedSettingsContext";
import { InsighSettingProperties } from "src/insights/insight";
import { insightService } from "src/insights/insightService";
import ContactsPlugin from "src/main";
interface InsightSettingsProps {
plugin: ContactsPlugin;
insightsSetting: InsighSettingProperties[]
}
export function InsightSettings({ plugin, insightsSetting }: InsightSettingsProps) {
export function InsightSettings({ plugin }: InsightSettingsProps) {
const insightsSetting = insightService.settings();
const containerRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (containerRef.current) {
insightsSetting.forEach((settingProps :InsighSettingProperties) => {
const settingKey = settingProps.settingPropertyName;
const currentValue = plugin.settings[settingKey];
const currentValue = plugin.settings.processors[settingKey];
if (typeof currentValue === 'boolean' && containerRef.current) {
new Setting(containerRef.current)
@ -27,7 +27,7 @@ export function InsightSettings({ plugin, insightsSetting }: InsightSettingsProp
toggle
.setValue(currentValue)
.onChange(async (value) => {
plugin.settings[settingKey] = value;
plugin.settings.processors[settingKey] = value;
await plugin.saveSettings();
setSettings(plugin.settings);
}));
@ -41,7 +41,7 @@ export function InsightSettings({ plugin, insightsSetting }: InsightSettingsProp
<div className="setting-item-spacer" ref={containerRef}>
<div className="setting-item setting-item-heading">
<div className="setting-item-info">
<div className="setting-item-name">Insights Processors</div>
<div className="setting-item-name">Insights processors</div>
<div className="setting-item-description"></div>
</div>
<div className="setting-item-control"></div>

View file

@ -1 +1,130 @@
export {}
import { App, Setting } from "obsidian";
import * as React from "react";
import ContactsPlugin from "src/main";
import { SyncSelected } from "src/settings/settings";
import { carddavGenericAdapter } from "src/sync/adapters/carddavGeneric";
import CarddavSettings from "src/ui/settings/components/carddavSettings";
interface SynchronizationSettingsProps {
plugin: ContactsPlugin;
app: App;
}
const initCardavSettings = {
addressBookUrl: "",
username: "",
password: "",
authKey: ""
};
export function SynchronizationSettings({plugin, app}: SynchronizationSettingsProps) {
const [warning, setWarning] = React.useState('');
const [syncEnabled, setSyncEnabled] = React.useState<boolean>(plugin.settings.CardDAV.syncEnabled);
const [syncSelected, setSyncSelected] = React.useState<SyncSelected>(plugin.settings.syncSelected);
const [carddavSettings, setCarddavSettings] = React.useState({
...initCardavSettings,
addressBookUrl: plugin.settings.CardDAV.addressBookUrl
});
const enableSync = async () => {
if (syncSelected !== 'CardDAV') {
setWarning('Kindly select a synchronization method and provide the required connection information. Thank you!');
return;
}
try {
const result = await carddavGenericAdapter().checkConnectivity(carddavSettings);
if (result.errorMessage) {
setWarning(`failed to enable connection! ${result.errorMessage}. Please check your connection settings.`);
return;
}
if (!(result.status >= 200 && result.status < 300)) {
setWarning('failed to enable connection! unknown error. Please check your connection settings.');
return;
}
plugin.settings.CardDAV = {
addressBookUrl: carddavSettings.addressBookUrl,
syncEnabled: true,
syncInterval: 900,
authType: carddavSettings.authKey ? 'apikey' : 'basic',
authKey: carddavSettings.authKey ? carddavSettings.authKey : btoa(`${carddavSettings.username}:${carddavSettings.password}`)
};
setCarddavSettings({
...initCardavSettings,
addressBookUrl: plugin.settings.CardDAV.addressBookUrl
});
setSyncEnabled(true);
setWarning('');
await plugin.saveSettings();
} catch (err: any) {
setWarning(`failed to enable connection! ${err?.message || err || 'Unknown error'}.`);
}
};
const disableSync = () => {
plugin.settings.CardDAV.syncEnabled = false;
plugin.saveSettings();
setSyncEnabled(false);
setSyncSelected('None');
}
// React.useEffect(() => {
// console.log(carddavSettings);
//
// },[carddavSettings]);
React.useEffect(() => {
plugin.settings.syncSelected = syncSelected;
plugin.saveSettings();
}, [syncSelected]);
return (
<>
<div className="setting-item js-keep">
<div className="setting-item-info">
<div className="setting-item-name">Synchronization</div>
<div className="setting-item-description">
{syncEnabled ? <div className="mod-success">CardDAV sync Enabled</div> : ''}
<div className="mod-warning">{warning}</div>
</div>
</div>
<div className="setting-item-control">
{syncEnabled ?
<button className="mod-destructive" onClick={disableSync}>Disable</button>
:
<button className="mod-cta" onClick={enableSync}>Enable</button>
}
</div>
</div>
<div className="setting-item js-keep">
<div className="setting-item-info">
<div className="setting-item-name">Sync method</div>
<div className="setting-item-description">Choose how you want to synchronize your contacts.</div>
</div>
<div className="setting-item-control">
<select
className="dropdown"
value={syncSelected}
onChange={e => {
disableSync();
setSyncSelected(e.target.value as SyncSelected);
}}>
<option value="None">No synchronization</option>
<option value="CardDAV">CardDAV address book</option>
</select>
</div>
</div>
{syncSelected === "CardDAV" && (
<CarddavSettings
carddavSettings={carddavSettings}
setCarddavSettings={setCarddavSettings}
/>
)}
</>
)
}

View file

@ -1,23 +1,11 @@
import { App, PluginSettingTab } from "obsidian";
import * as React from "react";
import { createRoot, Root } from "react-dom/client";
import { insightService } from "src/insights/insightService";
import ContactsPlugin from "src/main";
import { ContactsPluginSettings } from "src/settings/settings"
import { InsightSettings } from "src/ui/settings/components/insightsSettings";
import { MasterSetting } from "src/ui/settings/components/masterSettings";
import { SynchronizationSettings } from "src/ui/settings/components/synchronizationSettings";
const insightsSetting = insightService.settings();
const insightsSettingDefaults = insightsSetting.reduce((acc:Record<string, string|boolean>, setting) => {
acc[setting.settingPropertyName] = setting.settingDefaultValue;
return acc;
}, {} as Record<string, string>);
export const DEFAULT_SETTINGS: ContactsPluginSettings = {
contactsFolder: '',
defaultHashtag: '',
...insightsSettingDefaults
}
export class ContactsSettingTab extends PluginSettingTab {
plugin: ContactsPlugin;
@ -43,9 +31,11 @@ export class ContactsSettingTab extends PluginSettingTab {
/>
<InsightSettings
plugin={this.plugin}
insightsSetting={insightsSetting}
/>
<SynchronizationSettings
app={this.app}
plugin={this.plugin}
/>
</>
);
}

View file

@ -0,0 +1,93 @@
export interface AppHttpRequest {
url: string;
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'OPTIONS';
headers?: Record<string, string>;
body?: any;
}
export type AppHttpResponse = {
status: number;
data: any;
headers: Record<string, string>;
errorMessage: string;
};
const HTTP_STATUS_TITLES: Record<number, string> = {
400: '400 Bad Request',
401: '401 Unauthorized',
403: '403 Forbidden',
404: '404 Not Found',
500: '500 Internal Server Error',
// Add more as needed
};
export const PlatformHttpClient = {
async request({ url, method = 'GET', headers = {}, body }: AppHttpRequest) {
try {
const response = await nodeRequest(url, method, body, headers);
return {
...response,
errorMessage: HTTP_STATUS_TITLES[response.status]? HTTP_STATUS_TITLES[response.status] : ''
};
} catch (err: any) {
return {
status: 500,
data: '',
headers: {},
errorMessage: HTTP_STATUS_TITLES[500]
};
}
}
};
async function nodeRequest(
url: string,
method = 'GET',
body?: any,
headers: Record<string, string> = {}
): Promise<AppHttpResponse> {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { URL } = require('url');
const parsedUrl = new URL(url);
const protocol = parsedUrl.protocol === 'https:' ? 'node:https' : 'node:http';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const client = require(protocol);
return new Promise((resolve, reject) => {
const options = {
method,
hostname: parsedUrl.hostname,
port: parsedUrl.port,
path: parsedUrl.pathname + parsedUrl.search,
headers,
};
const req = client.request(options, (res: any) => {
let data = '';
res.setEncoding('utf8');
res.on('data', (chunk: string) => (data += chunk));
res.on('end', () => {
resolve({
status: res.statusCode || 0,
data,
headers: res.headers,
errorMessage: ''
});
});
});
req.on('error', () => {
reject();
});
if (body) {
if (typeof body === 'object' && !Buffer.isBuffer(body)) {
req.write(JSON.stringify(body));
} else {
req.write(body);
}
}
req.end();
});
}

View file

@ -12,7 +12,17 @@ import { afterEach,describe, expect, it, vi } from 'vitest';
const mockSettings: ContactsPluginSettings = {
contactsFolder: 'Contacts',
defaultHashtag: '',
enableSync: true,
processors: {
someRandomProcessor: true,
},
syncSelected: 'None',
CardDAV: {
addressBookUrl: '',
syncEnabled: false,
syncInterval: 900,
authKey: '',
authType: 'apikey'
}
};
describe('sharedSppContext', () => {