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](./README.md). For the contribution process, see [CONTRIBUTING.md](./CONTRIBUTING.md).
- Open the developer console with `Ctrl+Shift+I` (Windows) or `Command+Option+I` (macOS) and press `F5` (or `Ctrl+R` (Windows) or `Command+R` (macOS)) to reload the window, or
- Disable and re-enable the plugin in **Settings → Community plugins**.
> **Tip:** The [Hot Reload](https://github.com/pjeby/hot-reload) community plugin can reload the plugin automatically whenever `main.js` changes, removing the need to reload manually.
ESLint 10 is configured via [`eslint.config.mjs`](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.
User configuration is stored in `data.json` and loaded into `plugin.configuration` at startup. The runtime shape is composed in [`main.ts`](src/main.ts):
`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](#frontmatter-customization) for how that map is consumed.
## Contact Note: Storage vs. Runtime
The plugin splits contact-note concerns across two files:
- **[`ContactNote.ts`](src/ContactNote.ts)** — the on-disk schema and the `ContactNote` class. Holds the canonical list of built-in fields (their kinds, origins, and default icons) and exposes `getFields()`, `getField(key)`, `getReadKey(field)`, `getIcon(field)`, `applyCustomizations(...)`, and `buildContactNote(firstName, lastName, tag?)`. A single instance lives on the plugin as `plugin.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`](src/Contact.ts)** — the runtime model. `Contact.fromCache(file, frontmatter, frontmatterLinks, contactNote)` walks the field list, reads each value through `contactNote.getReadKey(field)`, and exposes typed fields (`firstName`, `emails`, `socials`, etc.) plus `isValid` and `getFieldLink(readKey)` for fields the user wrote as `[[Target]]` in frontmatter. The card renderer, panel view, and bases view all consume `Contact` instances 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`](src/ContactNoteSettingTab.ts).
How it flows through the code:
- On load and after every `saveConfiguration()`, [`main.ts`](src/main.ts) calls `plugin.contactNote.applyCustomizations(configuration.frontmatterCustomizations)`. That copies each override onto the matching `FieldDef` on the in-memory `ContactNote` instance.
-`ContactNote.getReadKey(field)` returns the override if set, otherwise the field's stable internal key. `getIcon(field)` does the same for icons, falling back to `field.defaultIcon`.
- All read/write sites use these resolvers rather than the literal field name: [`Contact.update()`](src/Contact.ts), [`ContactNote.buildContactNote()`](src/ContactNote.ts), [`buildContactCard()`](src/ContactCard.ts) (icons + the missing-required-fields error), [`ContactsBasesView.onDataUpdated()`](src/views/ContactsBasesView.ts), and the YAML emitted by [`ContactsBase.ts`](src/ContactsBase.ts) (`getDefaultPropertyOrder` and the `sort` block).
What overrides intentionally do NOT touch:
- Existing contact notes' frontmatter on disk: there is no rewrite step.
- Existing `.base` files: `order` and `sort` are written once at base creation time.
- The frontmatter filter editor in [`EditViewFilterModal`](src/modals/EditViewFilterModal.ts): 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.
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: true` on its `FieldDef` in `BUILTIN_FIELD_DEFS`.
-`Contact` stores `frontmatterLinks: FrontmatterLinkCache[]` populated from `cache.frontmatterLinks` at parse time. Every `Contact.fromCache` call site reads from the metadata cache and passes this list in.
-`Contact.getFieldLink(readKey)` returns the matching `FrontmatterLinkCache` entry (if any) for a given resolved frontmatter key.
- [`ContactCard.ts`](src/ContactCard.ts) `renderLinkableValue(...)` does the rendering: if the field is `allowsInternalLink` and `getFieldLink` returns an entry and `app.metadataCache.getFirstLinkpathDest(...)` resolves to a real file, it emits an `<a class="internal-link">` and wires a click handler to `app.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.
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 via `this.config.get(...)` in `onDataUpdated`.
- **Property reads.** Scalar and list frontmatter fields are read through Bases' query API (`entry.getValue("note.<field>")`). The `socials` field is read directly from `metadataCache` because Bases' `ObjectValue` has 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 opens `NewContactNoteModal`.
- **Default property order.** On first render of a fresh view, `ContactsBasesView` seeds the property order with `getDefaultPropertyOrder(plugin.contactNote)` from [`ContactsBase.ts`](src/ContactsBase.ts), which resolves the keys for `firstName`, `middleName`, `lastName`, and `displayName` through the user's frontmatter overrides before prefixing each with `note.`. Once the user customises the order, the seed is not reapplied.
[`ContactsBase.ts`](src/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 by `NewContactsBaseModal`). The `isContact` formula sits at the top level; the `filters.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 by `AppendContactsBaseViewModal`). It appends a new view block to an existing `.base`, inserts an `isContact` formula if the file doesn't already have one, and reports a `formulaMismatch` when 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`](src/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.
Plugin configurations are versioned through [`ConfigurationSchemaMigration.ts`](src/ConfigurationSchemaMigration.ts). The `schemaVersion` field on `ContactNoteConfiguration` records the version of the data on disk, and `CURRENT_SCHEMA_VERSION` (defined in [`main.ts`](src/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.
> Adding or removing a field that doesn't conflict with existing data does **not** require a migration. `loadConfiguration()` merges saved data over `DEFAULT_CONFIGURATION` (so new fields get their default) and drops keys that aren't in `DEFAULT_CONFIGURATION` (so removed fields disappear from `data.json` on the next save). A migration is only needed when an existing field needs to be renamed, restructured, or replaced with a non-default value.
2.**Add a step function** named `migrate_N_to_N+1(raw)` under the *Migration Step Functions* region of [`ConfigurationSchemaMigration.ts`](src/ConfigurationSchemaMigration.ts). It receives the previous-version shape as `any` and returns `Partial<ContactNoteConfiguration> & { schemaVersion: N+1 }`.
> 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.
- A Contacts base view (`.base` file using the plugin's view) renders entries, responds to its `Display` toggles, and the injected **New** button creates a contact in the configured contacts folder or with the configured tag in the vault's root.
- Auto-rename collision handling: creating a second contact with the same `First [Middle] Last` produces `First [Middle] Last 1.md` and surfaces a notice.
- Loading a `data.json` from a previous schema version triggers migration on first start, after which the file is rewritten in the current shape with `schemaVersion` stamped. Removed fields are stripped on save.
- Frontmatter customization: setting an override name for a built-in field causes new contact notes and newly created `.base` files to use the override; existing contact notes and existing `.base` files 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.
See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow, including how to file bugs and feature requests using the issue templates under [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/).
The release process is automated through two GitHub Actions workflows:
1.**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](https://semver.org/). The workflow updates `manifest.json`, `package.json`, and `versions.json`, commits the changes, and pushes a matching git tag.
2.**The release workflow runs automatically.** Pushing the tag triggers [`release.yml`](.github/workflows/release.yml), which validates that the tag matches `manifest.json`, builds the production bundle, and creates a GitHub release with `main.js`, `manifest.json`, and `styles.css` attached.
No local steps are required.
### Manual fallback
If the workflows are unavailable, the release can be performed manually:
1. Update the version in `manifest.json` and `package.json` to the new version number, following [Semantic Versioning](https://semver.org/).
2. Update `versions.json` to map the new plugin version to the minimum required Obsidian version.
3. Run `npm run build` to produce the production bundle.
4. Commit the version bump and tag the release: `git tag -a <version> -m "<version>"`.
5. Push the tag: `git push origin <version>`. This will still trigger the release workflow if it is operational; if not, continue to step 6.
6. Create a GitHub release attaching `main.js`, `manifest.json`, and `styles.css`.
- Open the developer console (`Ctrl+Shift+I` (Windows) or `Command+Option+I` (macOS)) and press `F5` (or `Ctrl+R` (Windows) or `Command+R` (macOS)) to reload the window.