21 KiB
Development
This document outlines how to set up a local development environment and the internals of the plugin for contributors. For end-user documentation, see README.md. For the contribution process, see CONTRIBUTING.md.
Prerequisites
- Node.js: v22 or later recommended
- Git: latest version
- Obsidian: 1.12.7 for the API's
Basesand a local vault for testing. Older versions will not load the plugin.
Getting Started
1. Clone the Repository
Clone the repository directly into your vault's plugin folder:
git clone <repository> <vault>/.obsidian/plugins/contact-note
cd <vault>/.obsidian/plugins/contact-note
Replace <vault> with the path to your Obsidian vault and <repository> with the link to your fork of the repository.
2. Install Dependencies
npm install
3. Start the Development Build
npm run dev
This starts esbuild in watch mode. It will rebuild main.js automatically whenever source files change.
4. Enable the Plugin in Obsidian
- Open Obsidian.
- Go to Settings → Community plugins.
- Enable Contact Note.
- After making changes, reload Obsidian to pick up the rebuilt
main.js. Either:- Open the developer console with
Ctrl+Shift+I(Windows) orCommand+Option+I(macOS) and pressF5(orCtrl+R(Windows) orCommand+R(macOS)) to reload the window, or - Disable and re-enable the plugin in Settings → Community plugins.
- Open the developer console with
Tip: The Hot Reload community plugin can reload the plugin automatically whenever
main.jschanges, removing the need to reload manually.
Linting
npm run lint
ESLint 10 is configured via eslint.config.mjs (flat config). The setup extends eslint-plugin-obsidianmd's recommended rules, which include Obsidian-specific guidance plus the typescript-eslint type-aware ruleset. Run lint before submitting a pull request.
Production Build
npm run build
This performs a TypeScript type-check followed by an optimized esbuild bundle. The output is main.js in the project root.
The files required for a release are:
main.jsmanifest.jsonstyles.css
Project Structure
contact-note/
├── src/
│ ├── main.ts # Plugin entry point, configuration, file events, view registration, ribbon, commands
│ ├── Contact.ts # Runtime contact model (parsed frontmatter)
│ ├── ContactNote.ts # On-disk contact note schema and the ContactNote class (built-in field list, key/icon resolution, customization application, template builder)
│ ├── ContactCard.ts # Shared contact card builder (used by reading view, panel view, and bases view)
│ ├── ContactsBase.ts # `.base` file builder and append-view mutator; owns DEFAULT_PROPERTY_ORDER
│ ├── ContactNoteSettingTab.ts # Settings tab UI; owns ContactNoteSettings and DEFAULT_SETTINGS
│ ├── ConfigurationSchemaMigration.ts # data.json migrations
│ ├── views/
│ │ ├── ContactsView.ts # Contacts view in a sidebar panel (ItemView); owns ContactsViewOptions and DEFAULT_VIEW_OPTIONS
│ │ └── ContactsBasesView.ts # Contacts view in a base (BasesView)
│ ├── modals/
│ │ ├── NewContactNoteModal.ts # "New contact" dialog
│ │ ├── NewContactsBaseModal.ts # "New base with contacts view" dialog
│ │ ├── AppendContactsBaseViewModal.ts # "Add contacts view to base" dialog
│ │ └── EditViewFilterModal.ts # Panel view filter editor
│ └── suggesters/
│ └── FolderSuggest.ts # Folder-path autocompletion for settings inputs
├── styles.css # Plugin styles
├── manifest.json # Obsidian plugin manifest
├── versions.json # Plugin/Obsidian version map
├── package.json # npm manifest and scripts
├── tsconfig.json # TypeScript config
├── esbuild.config.mjs # esbuild config
└── eslint.config.mjs # ESLint config
Configuration Shape
User configuration is stored in data.json and loaded into plugin.configuration at startup. The runtime shape is composed in main.ts:
type ContactNoteConfiguration =
{ schemaVersion: number }
& ContactNoteSettings // from ContactNoteSettingTab.ts, fields edited in the settings tab
& ContactsViewOptions; // from views/ContactsView.ts, per-view options edited from the ⋮ menu
DEFAULT_CONFIGURATION is the merged default of the two sub-defaults plus schemaVersion. loadSettings() runs the saved object through migrate(...) and then strips any keys not in DEFAULT_CONFIGURATION, so removed fields self-clean from data.json on the next save.
ContactNoteSettings includes a frontmatterCustomizations: Record<string, FrontmatterCustomization> field that holds the per-field key/icon overrides edited from the settings tab's customization grid. See Frontmatter Customization for how that map is consumed.
Contact Note: Storage vs. Runtime
The plugin splits contact-note concerns across two files:
ContactNote.ts— the on-disk schema and theContactNoteclass. Holds the canonical list of built-in fields (their kinds, origins, and default icons) and exposesgetFields(),getField(key),getReadKey(field),getIcon(field),applyCustomizations(...), andbuildContactNote(firstName, lastName, tag?). A single instance lives on the plugin asplugin.contactNote. Anything that needs to know what a contact note looks like on disk, or what frontmatter key/icon to use for a built-in field, should ask this instance.Contact.ts— the runtime model.Contact.fromCache(file, frontmatter, frontmatterLinks, contactNote)walks the field list, reads each value throughcontactNote.getReadKey(field), and exposes typed fields (firstName,emails,socials, etc.) plusisValidandgetFieldLink(readKey)for fields the user wrote as[[Target]]in frontmatter. The card renderer, panel view, and bases view all consumeContactinstances rather than raw frontmatter.
Frontmatter values that come through YAML as Date objects (e.g., bare ISO dates like 1990-04-15) are coerced to YYYY-MM-DD strings by trimStr in Contact.ts. All scalar fields end up as strings regardless of source type. There is no Date kind, and downstream code can assume string everywhere.
Adding or removing a built-in frontmatter field starts in BUILTIN_FIELD_DEFS (in ContactNote.ts). The Contact.update() loop, ContactNote.buildContactNote(), the bases view's per-entry read loop, and the settings-tab customization grid all iterate contactNote.getFields().
Frontmatter Customization
Users can override the frontmatter key the plugin reads from / writes to for any built-in field, and (for fields with a defaultIcon) the Lucide icon name shown on the contact card. Overrides live in configuration.frontmatterCustomizations as a Record<key, { keyOverride?: string; icon?: string }> and are edited through the customization grid in ContactNoteSettingTab.ts.
How it flows through the code:
- On load and after every
saveConfiguration(),main.tscallsplugin.contactNote.applyCustomizations(configuration.frontmatterCustomizations). That copies each override onto the matchingFieldDefon the in-memoryContactNoteinstance. ContactNote.getReadKey(field)returns the override if set, otherwise the field's stable internal key.getIcon(field)does the same for icons, falling back tofield.defaultIcon.- All read/write sites use these resolvers rather than the literal field name:
Contact.update(),ContactNote.buildContactNote(),buildContactCard()(icons + the missing-required-fields error),ContactsBasesView.onDataUpdated(), and the YAML emitted byContactsBase.ts(getDefaultPropertyOrderand thesortblock).
What overrides intentionally do NOT touch:
- Existing contact notes' frontmatter on disk: there is no rewrite step.
- Existing
.basefiles:orderandsortare written once at base creation time. - The frontmatter filter editor in
EditViewFilterModal: it operates on raw frontmatter using whatever key the user types in.
When adding a new built-in field that should support customization, add it to BUILTIN_FIELD_DEFS with origin: "builtin". The grid will pick it up automatically. Set defaultIcon only if the field renders an icon on the card; the grid uses defaultIcon to decide whether the icon column is editable for that row.
Internal Links in Frontmatter Values
Scalar built-in fields can opt in to rendering an Obsidian internal link ([[Target]] or [[target|Display]]) instead of plain text.
How it flows through the code:
- A field opts in via
allowsInternalLink: trueon itsFieldDefinBUILTIN_FIELD_DEFS. ContactstoresfrontmatterLinks: FrontmatterLinkCache[]populated fromcache.frontmatterLinksat parse time. EveryContact.fromCachecall site reads from the metadata cache and passes this list in.Contact.getFieldLink(readKey)returns the matchingFrontmatterLinkCacheentry (if any) for a given resolved frontmatter key.ContactCard.tsrenderLinkableValue(...)does the rendering: if the field isallowsInternalLinkandgetFieldLinkreturns an entry andapp.metadataCache.getFirstLinkpathDest(...)resolves to a real file, it emits an<a class="internal-link">and wires a click handler toapp.workspace.openLinkText(...)(respecting CTRL/Command-click for a new pane). Unresolved or non-link values fall back to plain text.
When adding a new field that should accept internal links, set allowsInternalLink: true on its FieldDef and call renderLinkableValue(...) instead of setText(...) at the render site. No other plumbing is required — the frontmatterLinks plumbing into Contact already covers any field.
Contacts Views in a (Sidebar) Panel or Base
The plugin registers two views under the same view type CONTACT_NOTE_LIST_VIEW_TYPE:
ContactsView(the contacts view in a sidebar panel) viaregisterViewContactsBasesView(the contacts view in a base) viaregisterBasesView
A few things worth knowing before changing the bases view:
- Per-base options. The
static getViewOptions(config)method returns the toggle group rendered in Bases' options panel. Toggle values are read at render time viathis.config.get(...)inonDataUpdated. - Property reads. Scalar and list frontmatter fields are read through Bases' query API (
entry.getValue("note.<field>")). Thesocialsfield is read directly frommetadataCachebecause Bases'ObjectValuehas no public key-enumeration API. - Injected New button. Bases' native New button creates a file at the vault root using only the visible columns' frontmatter, which is the wrong location and shape for a contact. It cannot be intercepted, so the native button is hidden via CSS scoped to the plugin's bases view, and
injectNewButton()adds a replacement that opensNewContactNoteModal. - Default property order. On first render of a fresh view,
ContactsBasesViewseeds the property order withgetDefaultPropertyOrder(plugin.contactNote)fromContactsBase.ts, which resolves the keys forfirstName,middleName,lastName, anddisplayNamethrough the user's frontmatter overrides before prefixing each withnote.. Once the user customises the order, the seed is not reapplied.
Base File Generation and Mutation
ContactsBase.ts is the single source for .base YAML emitted or modified by the plugin:
buildContactsBaseFile(...)produces the YAML written by Create new base with contacts view (driven byNewContactsBaseModal). TheisContactformula sits at the top level; thefilters.and: [- formula.isContact]block is written inside the view so multiple views in a base can opt in or out independently.appendContactsViewToBase(...)powers Add contacts view to base (driven byAppendContactsBaseViewModal). It appends a new view block to an existing.base, inserts anisContactformula if the file doesn't already have one, and reports aformulaMismatchwhen the existing formula doesn't match the current identification settings so the modal can surface a notice.
Both flows take useFolder, folderPath, tag, and the user-entered viewName from the modal. The base file folder is taken from the baseFolderPath setting; the base file name and view name are entered per-creation in the modal (there is no "default base file/view name" setting).
Plugin Event Bus
main.ts creates a plugin-scoped Events instance (plugin.events) and saveConfiguration() fires configuration-changed after every write. ContactsView subscribes to that event in onOpen and re-initialises its in-memory contacts on each fire, which is how the panel view picks up changes to Identify contacts by folder / folder path / tag without requiring the user to reopen it. New listeners that need to respond to settings changes should subscribe to the same event rather than polling configuration.
Configuration Schema Migrations
Plugin configurations are versioned through ConfigurationSchemaMigration.ts. The schemaVersion field on ContactNoteConfiguration records the version of the data on disk, and CURRENT_SCHEMA_VERSION (defined in main.ts) records the version the running code expects.
On every plugin load, loadConfiguration() runs the user's saved data through migrate(...), which steps the data forward one version at a time using the entries in the MIGRATIONS array. If anything was migrated, the upgraded settings are written back to disk so the user only pays the migration cost once.
Important
Adding or removing a field that doesn't conflict with existing data does not require a migration.
loadConfiguration()merges saved data overDEFAULT_CONFIGURATION(so new fields get their default) and drops keys that aren't inDEFAULT_CONFIGURATION(so removed fields disappear fromdata.jsonon the next save). A migration is only needed when an existing field needs to be renamed, restructured, or replaced with a non-default value.
Adding a new migration
When a configuration change would break existing user data (renamed field, restructured value, removed field with a non-default replacement, etc.):
- Bump
CURRENT_SCHEMA_VERSIONinmain.tsby one. - Add a step function named
migrate_N_to_N+1(raw)under the Migration Step Functions region ofConfigurationSchemaMigration.ts. It receives the previous-version shape asanyand returnsPartial<ContactNoteConfiguration> & { schemaVersion: N+1 }. - Register it by adding
{ from: N, to: N+1, apply: migrate_N_to_N+1 }to theMIGRATIONSarray. - Update the relevant type and default to reflect the new shape:
- For settings-tab fields, edit
ContactNoteSettingsandDEFAULT_SETTINGSinContactNoteSettingTab.ts. - For sidebar panel view options, edit
ContactsViewOptionsandDEFAULT_VIEW_OPTIONSinviews/ContactsView.ts.
- For settings-tab fields, edit
Important
Never edit a migration step after it has shipped. Users who already ran the old version of the step would silently desync from those who ran the new version. If a step needs correcting, write a new step that fixes the bad data forward.
Testing
Currently, the project relies on manual testing within an Obsidian vault. When making changes, please verify:
- The plugin loads without errors (check the developer console with
Ctrl+Shift+I(Windows) orCommand+Option+I(macOS)). - Existing contact notes still render correctly.
- Configuration persist across reloads.
- The Contacts view in a panel or base updates correctly when notes are added, modified, or deleted.
- A Contacts base view (
.basefile using the plugin's view) renders entries, responds to itsDisplaytoggles, and the injected New button creates a contact in the configured contacts folder or with the configured tag in the vault's root. - Both folder-based and tag-based contact identification work.
- Auto-rename collision handling: creating a second contact with the same
First [Middle] LastproducesFirst [Middle] Last 1.mdand surfaces a notice. - Loading a
data.jsonfrom a previous schema version triggers migration on first start, after which the file is rewritten in the current shape withschemaVersionstamped. Removed fields are stripped on save. - Frontmatter customization: setting an override name for a built-in field causes new contact notes and newly created
.basefiles to use the override; existing contact notes and existing.basefiles are unchanged. Setting a custom icon for frontmatter properties updates the contact card's icon for that field on the next render. - Internal links in frontmatter properties: setting a link-aware field to
[[Target]]where the target exists renders a clickable internal link on the contact card with hover-preview; setting it to[[Missing]]where no file exists renders the display text as plain text; setting it to plain text renders unchanged. The link rendering reflects target rename/create/delete on the next render. - The plugin renders and functions correctly in both desktop and mobile. All bugs, features, and UI changes should be verified against both before submission.
- The plugin renders correctly in both light and dark mode. All bugs, features, and UI changes should be verified against both themes before submission.
Submitting Changes
See CONTRIBUTING.md for the full workflow, including how to file bugs and feature requests using the issue templates under .github/ISSUE_TEMPLATE/.
Releasing
Important
Releasing is restricted to project maintainers.
The release process is automated through two GitHub Actions workflows:
- Trigger the version bump workflow. Go to Actions → Bump version → Run workflow and enter the new version (e.g.
1.1.0), following Semantic Versioning. The workflow updatesmanifest.json,package.json, andversions.json, commits the changes, and pushes a matching git tag. - The release workflow runs automatically. Pushing the tag triggers
release.yml, which validates that the tag matchesmanifest.json, builds the production bundle, and creates a GitHub release withmain.js,manifest.json, andstyles.cssattached.
No local steps are required.
Manual fallback
If the workflows are unavailable, the release can be performed manually:
- Update the version in
manifest.jsonandpackage.jsonto the new version number, following Semantic Versioning. - Update
versions.jsonto map the new plugin version to the minimum required Obsidian version. - Run
npm run buildto produce the production bundle. - Commit the version bump and tag the release:
git tag -a <version> -m "<version>". - Push the tag:
git push origin <version>. This will still trigger the release workflow if it is operational; if not, continue to step 6. - Create a GitHub release attaching
main.js,manifest.json, andstyles.css.
Troubleshooting Common Issues
Plugin does not appear in Obsidian
- Verify the plugin folder is at
<vault>/.obsidian/plugins/contact-note/. - Make sure
main.js,manifest.json, andstyles.cssare present in that folder. - Toggle Restricted mode off in Settings → Community plugins.
Changes are not reflected after rebuild
- Open the developer console (
Ctrl+Shift+I(Windows) orCommand+Option+I(macOS)) and pressF5(orCtrl+R(Windows) orCommand+R(macOS)) to reload the window. - Disable and re-enable the plugin in Settings → Community plugins.
- Restart Obsidian.