230 lines
12 KiB
Markdown
230 lines
12 KiB
Markdown
# Obsidian Exist Plugin — Design Spec
|
||
|
||
**Date:** 2026-03-27
|
||
**Status:** Approved
|
||
|
||
## Overview
|
||
|
||
A native Obsidian plugin (TypeScript) that syncs Exist.io personal tracking data into Obsidian daily notes. Replaces the existing Python CLI tool (`exist-client`) with an in-app experience that works on both desktop and mobile.
|
||
|
||
## Goals
|
||
|
||
- Sync Exist.io attribute data, insights, and tags into daily notes automatically on startup
|
||
- Provide manual sync and backfill commands
|
||
- Produce identical note output to the Python `exist-client` tool
|
||
- Work on desktop and mobile (iOS/Android)
|
||
- Feel like a polished, native Obsidian plugin
|
||
|
||
## Non-Goals
|
||
|
||
- Configurable attribute group visibility (can be added later)
|
||
- Writing data back to Exist.io
|
||
- Any UI beyond settings tab, ribbon icon, status bar, and notices
|
||
- Rate limiting or retry logic for API calls (deferred)
|
||
- Automated tests (manual testing against real API and vault)
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
The plugin is structured as five focused modules:
|
||
|
||
```
|
||
obsidian-exist/
|
||
├── src/
|
||
│ ├── main.ts — Plugin entry point, lifecycle, commands, ribbon, status bar
|
||
│ ├── api.ts — Exist.io API client
|
||
│ ├── notes.ts — Note rendering, section replacement, frontmatter
|
||
│ ├── settings.ts — Settings tab UI and defaults
|
||
│ └── daily-notes.ts — Daily Notes / Periodic Notes plugin path resolution
|
||
├── manifest.json
|
||
├── package.json
|
||
├── esbuild.config.mjs
|
||
└── tsconfig.json
|
||
```
|
||
|
||
**Data flow:** On startup (and on manual trigger), `main.ts` resolves the target date(s), calls `api.ts` to fetch Exist data, then calls `notes.ts` to render and write each note. File paths are resolved at sync time via `daily-notes.ts`.
|
||
|
||
---
|
||
|
||
## Module Designs
|
||
|
||
### `api.ts` — Exist.io API Client
|
||
|
||
- Uses Obsidian's `requestUrl` for all HTTP calls (required for mobile compatibility; never use `fetch` or Node built-ins)
|
||
- Bearer token authentication via `Authorization: Bearer <token>` header
|
||
- Base URL: `https://exist.io/api/2`
|
||
- Fetches from two endpoints:
|
||
- `attributes/with-values/` — query params: `date_max` (ISO date of most recent day), `days` (count of days in range), `limit: 100`
|
||
- `insights/` — query params: `date_min` (ISO date), `date_max` (ISO date), `limit: 100`
|
||
- Handles pagination by following the `next` URL in the response body (only the first request sends query params; subsequent pagination requests follow `next` as-is)
|
||
- Maximum 31-day range — backfill input is clamped to 31
|
||
|
||
**Data types:**
|
||
- `AttrValue`: `{ value: any, valueType: number, label: string, group: string, groupLabel: string }`
|
||
- `ExistData`: `{ date: string, attrs: Record<string, AttrValue>, tags: string[], insights: string[] }`
|
||
- The `attrs` map key is the attribute `name` field from the API response (e.g. `"mood"`, `"steps"`)
|
||
|
||
**Tag data source:** `ExistData.tags` is populated exclusively from boolean custom attributes — there is no separate tags endpoint. Non-custom boolean attributes do not produce tags.
|
||
|
||
**Boolean-to-tag conversion** — handled entirely in `api.ts`, not in `notes.ts`:
|
||
- Attribute `valueType === 7` (boolean) AND `group === "custom"` AND `value === 1` → label is added to `ExistData.tags`
|
||
- These attributes are never stored in `attrs`
|
||
- All other valueType 7 attributes (non-custom) are stored in `attrs` normally
|
||
|
||
**Insight shape:** Each insight object from the API has `target_date` (ISO date string) and `text` (string). Only the `text` field is stored in `ExistData.insights`.
|
||
|
||
---
|
||
|
||
### `notes.ts` — Note Rendering & File I/O
|
||
|
||
Identical logic to Python `notes.py`.
|
||
|
||
**Value formatting** (by `valueType`):
|
||
- `0` (integer) / `8` (scale): `str(int(value))` → e.g. `8432`
|
||
- `1` (float): one decimal place → e.g. `6.3`
|
||
- `3` (duration, in minutes): `7h 12m`; if under 60 minutes: `45m`
|
||
- `5` (percentage): one decimal place + `%` → e.g. `23.4%`
|
||
- `2` (string), `4` (TimeOfDay), `6` (Period), unknown: `str(value)` → e.g. `Berlin`, `23:15`
|
||
|
||
**Zero-omission rules:**
|
||
- Omit if `valueType` is `0`, `3`, `5`, or `8` (integer, duration, percentage, scale) AND value equals `0`
|
||
- Exception: `mood` (by attribute name) is never omitted regardless of value
|
||
- `valueType 1` (float): zero is a legitimate value — never omitted
|
||
- `valueType 2` (string): empty string is a legitimate value — never omitted
|
||
|
||
**Attribute grouping:** Rendered in this order by group `name` (short name from the API response `group.name` field, not the display label):
|
||
```
|
||
["mood", "sleep", "activity", "workouts", "productivity", "health",
|
||
"food and drink", "finance", "events", "location", "media", "social",
|
||
"weather", "twitter"]
|
||
```
|
||
This matches the Python `exist-client` source. Unknown groups fall after `"twitter"`, sorted alphabetically.
|
||
|
||
**Implementation note:** On the first development run, log the raw `group.name` values from the API response to verify the exact strings for multi-word groups (e.g. `"food and drink"` vs `"food_and_drink"`). Update the array if they differ from the list above. The ordering comparison is a literal string match.
|
||
|
||
**Group rendering:** Each group gets a `### GroupLabel` heading (using the `groupLabel` from the API). The `custom` group is handled specially (see below).
|
||
|
||
**`mood_note` rendering:** Within the Mood group, `mood_note` is deferred and rendered as a blockquote at the end of the group, after all other mood attributes, separated by a blank line: `> text`. If the value is blank/whitespace, it is omitted entirely.
|
||
|
||
**Custom group:** Non-boolean custom attributes and tags are rendered together under `### Custom`. Non-boolean custom attrs appear as Dataview-style inline fields (`Label:: value`) — same format as all other attributes. Boolean custom attrs (converted to tags) appear as `Tags:: tag1, tag2` at the end of the section.
|
||
|
||
**Insights:** Rendered under `### Insights` as blockquotes: `> insight text`. One line per insight.
|
||
|
||
**Section replacement:**
|
||
1. Find the line `## Exist` in the note content
|
||
2. Find the end of the section: next `## ` heading or EOF
|
||
3. Replace the range in-place, stripping trailing blank lines from the old section
|
||
4. Append a blank line separator before any content that follows (only if there is content after the section)
|
||
5. If no `## Exist` section exists, append the new section to the end of the note (with a blank line separator if the note is non-empty)
|
||
6. All other note content is preserved exactly
|
||
7. The rendered section always ends with exactly one `\n`; no additional trailing newline is added when the section is at the end of the file
|
||
|
||
**Frontmatter update strategy:**
|
||
- Parse the YAML frontmatter block into a key-value map
|
||
- Update only `mood` (integer, from `attrs["mood"].value`) and `exist_tags` (list of tag strings)
|
||
- All other existing frontmatter keys are preserved untouched
|
||
- Re-serialize the full frontmatter block; this may reorder keys relative to hand-edited frontmatter (acceptable trade-off)
|
||
- `exist_tags` is always written, even if empty (written as `exist_tags: []`)
|
||
- `mood` is only written if the `mood` attribute is present in the data
|
||
|
||
**New note creation:** When the daily note does not exist:
|
||
1. Create all parent directories recursively (on mobile: call `vault.adapter.mkdir` for each intermediate path; `vault.create` does not create parent directories automatically)
|
||
2. Create the file with default frontmatter: `created: <ISO date>` and `up: "[[Calendar]]"`
|
||
3. Then apply the normal section replacement logic (appends `## Exist` to empty content)
|
||
|
||
**Idempotency:** Re-syncing the same date produces identical output.
|
||
|
||
---
|
||
|
||
### `daily-notes.ts` — Path Resolution
|
||
|
||
Resolves the vault-relative file path for a given date by reading configuration from whichever daily notes plugin is active.
|
||
|
||
**Plugin precedence:** If both are active, **Periodic Notes** takes precedence over Daily Notes.
|
||
|
||
**Periodic Notes plugin** (`periodic-notes`, community plugin):
|
||
- Settings accessed via: `(app.plugins.plugins['periodic-notes'] as any)?.settings?.daily`
|
||
- Relevant fields: `folder` (string, vault-relative folder), `format` (moment.js date format string, e.g. `YYYY-MM-DD`)
|
||
|
||
**Daily Notes plugin** (`daily-notes`, Obsidian core plugin):
|
||
- Settings accessed via: `(app.internalPlugins.getPluginById('daily-notes') as any)?.instance?.options`
|
||
- Relevant fields: `folder` (string), `format` (moment.js date format string)
|
||
|
||
**Path construction:** `<folder>/<date formatted with format>.md` — use moment.js (bundled with Obsidian) to format the date.
|
||
|
||
**Fallback:** If neither plugin is installed/enabled, show a Notice: "Obsidian Exist: Daily Notes or Periodic Notes plugin required. Please enable one and configure it." and abort the sync.
|
||
|
||
---
|
||
|
||
### `settings.ts` — Settings
|
||
|
||
**User-facing settings:**
|
||
- `existToken` (string) — Exist.io personal API token; password-masked input field; link to `exist.io/account/api/`
|
||
- `syncOnStartup` (boolean, default: `true`) — whether to sync yesterday on Obsidian open
|
||
|
||
**Internally stored (not user-facing, stored in `data.json`):**
|
||
- `lastSyncedDate` (string, ISO date, default: `""`) — the most recently successfully synced date; drives both the status bar display and startup sync logic. For backfill, this is updated after each day completes (set to that day's date), so it reflects the most recently written note even if backfill is interrupted mid-run.
|
||
|
||
---
|
||
|
||
### `main.ts` — Plugin Entry Point
|
||
|
||
**On load:**
|
||
1. Load settings from `data.json`
|
||
2. Register ribbon icon (calendar icon)
|
||
3. Register status bar item (desktop only; skip on mobile via `Platform.isMobile`)
|
||
4. Register commands
|
||
5. Update status bar from `lastSyncedDate`
|
||
6. If `syncOnStartup` is enabled and token is set: sync yesterday silently
|
||
|
||
**Commands:**
|
||
- `"Sync Exist.io data"` — syncs yesterday; available in command palette; assignable to hotkey
|
||
- `"Backfill Exist.io data"` — opens a modal with a number input (`<input type="number" min="1" max="31">`) labelled "How many days back?", plus OK and Cancel buttons. On OK: non-numeric or out-of-range values are clamped to 1–31; blank/empty input aborts with no action. On Cancel: close modal, no sync. Syncs the resulting range with progress notices.
|
||
|
||
**Backfill date range:** `date_max` is yesterday; days are processed newest-first (yesterday, then day before, etc.) so the most recent data appears quickly.
|
||
|
||
**Ribbon icon:** Triggers "Sync Exist.io data" (same as command).
|
||
|
||
**Status bar item (desktop only):**
|
||
- `"Exist: YYYY-MM-DD"` (the actual date from `lastSyncedDate`) after a successful sync
|
||
- `"Exist: never"` if `lastSyncedDate` is empty
|
||
- `"Exist: error"` if the last sync failed
|
||
- Clicking it triggers "Sync Exist.io data"
|
||
|
||
**Notice behavior:**
|
||
- Startup sync: silent on success; Notice on error only
|
||
- Manual sync: Notice on success (`"Exist.io synced"`) and on error
|
||
- Backfill: progress Notice (`"Exist.io: syncing 3/7…"`) updated as each day completes; final success/error Notice at end
|
||
|
||
---
|
||
|
||
## Error Handling
|
||
|
||
- Missing token: Notice prompting user to open plugin settings
|
||
- Daily Notes / Periodic Notes plugin not found/configured: Notice explaining the dependency; abort sync
|
||
- API 401: Notice "Exist.io: invalid token. Check plugin settings."
|
||
- Network error: Notice "Exist.io: network error. Check your connection."
|
||
- Date with no Exist data: Skip silently (log to console), continue to next date
|
||
- All errors set status bar to `"Exist: error"` (desktop only)
|
||
|
||
---
|
||
|
||
## Mobile Compatibility
|
||
|
||
- All HTTP: `requestUrl` only — never `fetch`, `XMLHttpRequest`, or Node built-ins
|
||
- File creation: `vault.adapter.mkdir` called recursively for parent directories before `vault.create`
|
||
- Status bar: registered only on desktop (`Platform.isMobile` guard)
|
||
- No `path`, `fs`, or other Node.js module imports
|
||
- `moment` (date formatting): imported from Obsidian's bundled version, not npm
|
||
|
||
---
|
||
|
||
## Dependencies
|
||
|
||
- **Obsidian API** — all platform APIs (vault, settings, UI, moment)
|
||
- **Daily Notes / Periodic Notes plugin** — runtime dependency for note path resolution; not bundled
|
||
- No external npm runtime dependencies
|
||
|
||
**Dev dependencies:** esbuild (bundler), TypeScript, `@types/node`, `obsidian` type definitions
|