# Obsidian Exist Plugin Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a native Obsidian plugin (TypeScript) that syncs Exist.io personal tracking data into daily notes, replacing the Python `exist-client` CLI tool. **Architecture:** Five focused modules — `api.ts` (Exist.io API client), `notes.ts` (note rendering and file I/O), `daily-notes.ts` (path resolution from Daily Notes / Periodic Notes plugin), `settings.ts` (settings UI), `main.ts` (plugin entry point, commands, ribbon, status bar). No external runtime npm dependencies; all HTTP via Obsidian's `requestUrl` for mobile compatibility. **Tech Stack:** TypeScript, Obsidian Plugin API, esbuild (bundler), obsidian npm package (types + API) **Spec:** `docs/superpowers/specs/2026-03-27-obsidian-exist-plugin-design.md` --- ## File Map | File | Purpose | |------|---------| | `manifest.json` | Obsidian plugin manifest (id, version, minAppVersion) | | `package.json` | npm scripts and dev dependencies | | `tsconfig.json` | TypeScript compiler config | | `esbuild.config.mjs` | Build script: bundles `src/main.ts` → `main.js` | | `.gitignore` | Ignore `node_modules/`, `main.js`, `.obsidian/` | | `src/api.ts` | `fetchRange()` — calls Exist.io API, paginates, returns `Record` | | `src/notes.ts` | `renderExistSection()`, `replaceExistSection()`, `updateNote()` — all note logic | | `src/daily-notes.ts` | `getDailyNotePath()` — reads Daily Notes / Periodic Notes plugin config | | `src/settings.ts` | `ExistSettings` interface, `DEFAULT_SETTINGS`, `ExistSettingTab` class | | `src/main.ts` | `ExistPlugin` class, `BackfillModal` class | --- ## Task 1: Project Scaffolding **Files:** - Create: `manifest.json` - Create: `package.json` - Create: `tsconfig.json` - Create: `esbuild.config.mjs` - Create: `.gitignore` - [ ] **Step 1: Create `manifest.json`** ```json { "id": "obsidian-exist", "name": "Exist", "version": "1.0.0", "minAppVersion": "0.15.0", "description": "Sync Exist.io personal data into your daily notes", "author": "Sven Giersig", "authorUrl": "", "isDesktopOnly": false } ``` - [ ] **Step 2: Create `package.json`** ```json { "name": "obsidian-exist", "version": "1.0.0", "description": "Sync Exist.io data into Obsidian daily notes", "main": "main.js", "scripts": { "dev": "node esbuild.config.mjs", "build": "node esbuild.config.mjs production" }, "keywords": [], "author": "", "license": "MIT", "devDependencies": { "@types/node": "^16.11.6", "builtin-modules": "^3.3.0", "esbuild": "0.17.3", "obsidian": "latest", "tslib": "2.4.0", "typescript": "4.7.4" } } ``` - [ ] **Step 3: Create `tsconfig.json`** ```json { "compilerOptions": { "baseUrl": ".", "inlineSourceMap": true, "inlineSources": true, "module": "ESNext", "target": "ES6", "allowSyntheticDefaultImports": true, "moduleResolution": "bundler", "importHelpers": true, "isolatedModules": true, "strictNullChecks": true, "lib": ["DOM", "ES5", "ES6", "ES7"] }, "include": ["src/**/*.ts"] } ``` - [ ] **Step 4: Create `esbuild.config.mjs`** ```js import esbuild from "esbuild"; import process from "process"; import builtins from "builtin-modules"; const prod = process.argv[2] === "production"; const context = await esbuild.context({ entryPoints: ["src/main.ts"], bundle: true, external: [ "obsidian", "electron", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr", ...builtins, ], format: "cjs", target: "es2018", logLevel: "info", sourcemap: prod ? false : "inline", treeShaking: true, outfile: "main.js", }); if (prod) { await context.rebuild(); process.exit(0); } else { await context.watch(); } ``` - [ ] **Step 5: Create `src/` directory and `.gitignore`** ``` node_modules/ main.js *.js.map .obsidian/ ``` Run: `mkdir src` - [ ] **Step 6: Install dependencies** Run: `npm install` Expected: `node_modules/` created, no errors. - [ ] **Step 7: Commit** ```bash git add manifest.json package.json package-lock.json tsconfig.json esbuild.config.mjs .gitignore git commit -m "chore: project scaffolding" ``` --- ## Task 2: API Client (`src/api.ts`) **Files:** - Create: `src/api.ts` This module fetches Exist.io data for a date range and returns it as a map of `date → ExistData`. All HTTP is done via Obsidian's `requestUrl` (mobile-compatible). No Node.js built-ins. - [ ] **Step 1: Create `src/api.ts` with types and `fetchRange`** ```typescript import { requestUrl } from "obsidian"; const BASE_URL = "https://exist.io/api/2"; export interface AttrValue { value: unknown; valueType: number; label: string; group: string; groupLabel: string; } export interface ExistData { date: string; // ISO date, e.g. "2026-03-26" attrs: Record; // keyed by attribute name, e.g. "mood", "steps" tags: string[]; // labels of active boolean custom attributes insights: string[]; // insight text strings } export class ExistApiError extends Error { constructor(public status: number, public url: string) { super(`Exist API error ${status} at ${url}`); } } /** * Fetch all Exist.io data for a date range (max 31 days). * dateMin and dateMax are ISO date strings (YYYY-MM-DD). * Returns a map of ISO date string → ExistData. */ export async function fetchRange( token: string, dateMin: string, dateMax: string ): Promise> { const headers = { Authorization: `Bearer ${token}` }; const results: Record = {}; const daysCount = daysBetween(dateMin, dateMax) + 1; // Fetch attributes with values const attrUrl = new URL(`${BASE_URL}/attributes/with-values/`); attrUrl.searchParams.set("date_max", dateMax); attrUrl.searchParams.set("days", String(daysCount)); attrUrl.searchParams.set("limit", "100"); const attrItems = await paginate(attrUrl.toString(), headers); for (const attrObj of attrItems as Record[]) { const vtype = attrObj["value_type"] as number; const groupObj = (attrObj["group"] ?? {}) as Record; const group = groupObj["name"] ?? ""; const groupLabel = groupObj["label"] ?? group; const label = attrObj["label"] as string; const name = attrObj["name"] as string; for (const v of (attrObj["values"] ?? []) as Array<{ value: unknown; date: string }>) { if (v.value === null || v.value === undefined) continue; const d = v.date; if (vtype === 7) { // Boolean attribute: only active (value=1) custom group attrs become tags if (group === "custom" && v.value === 1) { ensure(results, d); results[d].tags.push(label); } // All other boolean attrs are dropped } else { ensure(results, d); results[d].attrs[name] = { value: v.value, valueType: vtype, label, group, groupLabel, }; } } } // Fetch insights const insightUrl = new URL(`${BASE_URL}/insights/`); insightUrl.searchParams.set("date_min", dateMin); insightUrl.searchParams.set("date_max", dateMax); insightUrl.searchParams.set("limit", "100"); const insightItems = await paginate(insightUrl.toString(), headers); for (const insight of insightItems as Record[]) { const target = insight["target_date"] as string; const text = insight["text"] as string; if (!target || !text) continue; if (target < dateMin || target > dateMax) continue; ensure(results, target); results[target].insights.push(text); } return results; } function ensure(results: Record, date: string): void { if (!results[date]) { results[date] = { date, attrs: {}, tags: [], insights: [] }; } } async function paginate( firstUrl: string, headers: Record ): Promise { const items: unknown[] = []; let url: string | null = firstUrl; while (url) { const resp = await requestUrl({ url, headers }); if (resp.status !== 200) { throw new ExistApiError(resp.status, url); } const body = resp.json as { results?: unknown[]; next?: string | null }; items.push(...(body.results ?? [])); url = body.next ?? null; } return items; } function daysBetween(dateMin: string, dateMax: string): number { const d1 = new Date(dateMin).getTime(); const d2 = new Date(dateMax).getTime(); return Math.round((d2 - d1) / (1000 * 60 * 60 * 24)); } ``` - [ ] **Step 2: Verify it type-checks** Run: `npx tsc --noEmit` Expected: No errors (or only errors from files not yet created — that's fine at this stage). - [ ] **Step 3: Commit** ```bash git add src/api.ts git commit -m "feat: Exist.io API client" ``` --- ## Task 3: Note Rendering (`src/notes.ts`) **Files:** - Create: `src/notes.ts` This module contains all note logic: value formatting, group ordering, section rendering, section replacement, frontmatter update, and file I/O. It has no side effects beyond reading/writing vault files. - [ ] **Step 1: Create `src/notes.ts`** ```typescript import { App, normalizePath, parseYaml, stringifyYaml, TFile } from "obsidian"; import { AttrValue, ExistData } from "./api"; // Group render order by API short name (group.name field, not display label). // IMPORTANT: On first development run, log raw group.name values from the API // and verify these strings match. Multi-word group names (e.g. "food and drink") // may use spaces or underscores in the actual API response. const GROUP_ORDER = [ "mood", "sleep", "activity", "workouts", "productivity", "health", "food and drink", "finance", "events", "location", "media", "social", "weather", "twitter", ]; // --- Value Formatting --- export function formatValue(value: unknown, valueType: number): string { if (valueType === 0 || valueType === 8) { // integer or scale return String(Math.floor(Number(value))); } if (valueType === 1) { // float: one decimal place return Number(value).toFixed(1); } if (valueType === 3) { // duration in minutes const minutes = Math.floor(Number(value)); if (minutes < 60) return `${minutes}m`; const h = Math.floor(minutes / 60); const m = minutes % 60; return `${h}h ${m}m`; } if (valueType === 5) { // percentage return `${Number(value).toFixed(1)}%`; } // string, TimeOfDay, Period, unknown return String(value); } // --- Zero Omission --- function isZeroOmittable(name: string, value: unknown, valueType: number): boolean { if (name === "mood") return false; // mood is never omitted // integer (0), duration (3), percentage (5), scale (8): omit if value is 0 if (valueType === 0 || valueType === 3 || valueType === 5 || valueType === 8) { return value === 0; } // float, string, TimeOfDay, Period: 0 and "" are legitimate values, never omit return false; } // --- Group Sorting --- function groupSortKey(group: string): [number, string] { const idx = GROUP_ORDER.indexOf(group); return idx >= 0 ? [idx, group] : [GROUP_ORDER.length, group]; } // --- Section Rendering --- /** * Render the full ## Exist section as a string. * The returned string always ends with exactly one \n. */ export function renderExistSection(data: ExistData): string { const lines: string[] = ["## Exist"]; // Bucket non-zero attrs by group const groups: Record> = {}; for (const [name, av] of Object.entries(data.attrs)) { if (isZeroOmittable(name, av.value, av.valueType)) continue; if (!groups[av.group]) groups[av.group] = []; groups[av.group].push([name, av]); } // Render non-custom groups in predefined order const nonCustomGroups = Object.keys(groups).filter((g) => g !== "custom"); nonCustomGroups.sort((a, b) => { const [ai, as_] = groupSortKey(a); const [bi, bs] = groupSortKey(b); return ai !== bi ? ai - bi : as_.localeCompare(bs); }); for (const groupName of nonCustomGroups) { const attrsInGroup = groups[groupName]; const groupLabel = attrsInGroup[0][1].groupLabel; lines.push("", `### ${groupLabel}`, ""); let moodNoteVal: string | null = null; for (const [name, av] of attrsInGroup) { if (name === "mood_note") { // Deferred: rendered as blockquote at end of group const val = String(av.value).trim(); moodNoteVal = val || null; continue; } lines.push(`${av.label}:: ${formatValue(av.value, av.valueType)}`); } if (moodNoteVal) { lines.push("", `> ${moodNoteVal}`); } } // Insights subsection if (data.insights.length > 0) { lines.push("", "### Insights", ""); for (const text of data.insights) { lines.push(`> ${text}`); } } // Custom group: non-boolean custom attrs + tags line const customAttrs = groups["custom"] ?? []; if (customAttrs.length > 0 || data.tags.length > 0) { lines.push("", "### Custom", ""); for (const [, av] of customAttrs) { lines.push(`${av.label}:: ${formatValue(av.value, av.valueType)}`); } if (data.tags.length > 0) { lines.push(`Tags:: ${data.tags.join(", ")}`); } } return lines.join("\n") + "\n"; } // --- Section Replacement --- /** * Replace the ## Exist section in `content` with `newSection`. * If no ## Exist section exists, appends to end. * Preserves all other content exactly. */ export function replaceExistSection(content: string, newSection: string): string { const lines = content.split("\n"); // Find ## Exist heading let start = -1; for (let i = 0; i < lines.length; i++) { if (lines[i].trimEnd() === "## Exist") { start = i; break; } } if (start === -1) { // Append to end const stripped = content.trimEnd(); if (!stripped) return newSection; return stripped + "\n\n" + newSection; } // Find end of section: next ## heading or EOF let end = lines.length; for (let i = start + 1; i < lines.length; i++) { if (lines[i].startsWith("## ")) { end = i; break; } } // Strip trailing blank lines from the old section range while (end > start + 1 && lines[end - 1].trim() === "") { end--; } const before = lines.slice(0, start); let after = lines.slice(end); // Strip leading blank lines from after while (after.length > 0 && after[0].trim() === "") { after = after.slice(1); } // Rebuild: before + newSection + blank separator + after (if any) const beforeStr = before.length > 0 ? before.join("\n") + "\n" : ""; if (after.length > 0) { return beforeStr + newSection + "\n" + after.join("\n"); } return beforeStr + newSection; } // --- Frontmatter --- interface ParsedNote { frontmatter: Record; body: string; } function parseNote(content: string): ParsedNote { if (content.startsWith("---\n")) { const endIdx = content.indexOf("\n---\n", 4); if (endIdx !== -1) { const yamlStr = content.slice(4, endIdx); const fm = (parseYaml(yamlStr) as Record) ?? {}; const body = content.slice(endIdx + 5); // skip "\n---\n" return { frontmatter: fm, body }; } } return { frontmatter: {}, body: content }; } function serializeNote(frontmatter: Record, body: string): string { return `---\n${stringifyYaml(frontmatter)}---\n${body}`; } // --- File I/O --- /** * Create all ancestor directories of a vault path. * vault.create() does not create parents automatically on mobile. */ async function ensureParentDirs(app: App, filePath: string): Promise { const dir = filePath.substring(0, filePath.lastIndexOf("/")); if (!dir) return; const parts = dir.split("/").filter(Boolean); let current = ""; for (const part of parts) { current = current ? `${current}/${part}` : part; if (!app.vault.getAbstractFileByPath(current)) { try { await app.vault.adapter.mkdir(normalizePath(current)); } catch { // Already exists — safe to ignore } } } } /** * Update (or create) the daily note at `filePath` with Exist data. * Idempotent: re-running with the same data produces identical output. */ export async function updateNote( app: App, data: ExistData, filePath: string ): Promise { const normalized = normalizePath(filePath); let content: string; let tfile: TFile; const existing = app.vault.getAbstractFileByPath(normalized); if (existing instanceof TFile) { tfile = existing; content = await app.vault.read(tfile); } else { // Create new note with default frontmatter await ensureParentDirs(app, normalized); const defaultFm: Record = { created: data.date, up: "[[Calendar]]", }; const initialContent = `---\n${stringifyYaml(defaultFm)}---\n`; tfile = await app.vault.create(normalized, initialContent); content = initialContent; } // Parse frontmatter and body const { frontmatter: fm, body } = parseNote(content); // Update frontmatter keys fm.exist_tags = data.tags; const moodAttr = data.attrs["mood"]; if (moodAttr !== undefined) { fm.mood = Number(moodAttr.value); } // Update Exist section in body const section = renderExistSection(data); const newBody = replaceExistSection(body, section); // Write back await app.vault.modify(tfile, serializeNote(fm, newBody)); } ``` - [ ] **Step 2: Verify it type-checks** Run: `npx tsc --noEmit` Expected: No errors from files created so far (api.ts, notes.ts). - [ ] **Step 3: Commit** ```bash git add src/notes.ts git commit -m "feat: note rendering and file I/O" ``` --- ## Task 4: Daily Notes Path Resolution (`src/daily-notes.ts`) **Files:** - Create: `src/daily-notes.ts` This module reads the Daily Notes or Periodic Notes plugin's configured folder and date format, then constructs the vault-relative path for a given date. Periodic Notes takes precedence when both are active. - [ ] **Step 1: Create `src/daily-notes.ts`** ```typescript import { App, moment } from "obsidian"; /** * Resolve the vault-relative file path for a daily note on `date` (ISO date string). * Reads config from Periodic Notes (preferred) or Daily Notes core plugin. * Throws with message "daily-notes-not-configured" if neither is available. */ export function getDailyNotePath(app: App, date: string): string { const m = moment(date, "YYYY-MM-DD"); // Periodic Notes community plugin takes precedence // eslint-disable-next-line @typescript-eslint/no-explicit-any const periodicNotes = (app as any).plugins?.plugins?.["periodic-notes"]; if (periodicNotes?.settings?.daily) { const { folder = "", format = "YYYY-MM-DD" } = periodicNotes.settings.daily as { folder?: string; format?: string; }; return buildPath(folder, m.format(format)); } // Daily Notes core plugin // eslint-disable-next-line @typescript-eslint/no-explicit-any const dailyNotes = (app as any).internalPlugins?.getPluginById?.("daily-notes"); const options = dailyNotes?.instance?.options; if (options !== undefined) { const { folder = "", format = "YYYY-MM-DD" } = options as { folder?: string; format?: string; }; return buildPath(folder, m.format(format)); } throw new Error("daily-notes-not-configured"); } function buildPath(folder: string, formattedDate: string): string { const cleanFolder = folder.replace(/\/$/, ""); // strip trailing slash return cleanFolder ? `${cleanFolder}/${formattedDate}.md` : `${formattedDate}.md`; } ``` - [ ] **Step 2: Verify it type-checks** Run: `npx tsc --noEmit` Expected: No errors from files created so far. - [ ] **Step 3: Commit** ```bash git add src/daily-notes.ts git commit -m "feat: Daily Notes / Periodic Notes path resolution" ``` --- ## Task 5: Settings (`src/settings.ts`) **Files:** - Create: `src/settings.ts` Defines the settings schema and the Obsidian settings tab UI. The tab shows the API token (password-masked) and a sync-on-startup toggle. - [ ] **Step 1: Create `src/settings.ts`** ```typescript import { App, PluginSettingTab, Setting } from "obsidian"; import type ExistPlugin from "./main"; export interface ExistSettings { existToken: string; syncOnStartup: boolean; lastSyncedDate: string; // ISO date of most recently synced note; "" if never synced } export const DEFAULT_SETTINGS: ExistSettings = { existToken: "", syncOnStartup: true, lastSyncedDate: "", }; export class ExistSettingTab extends PluginSettingTab { plugin: ExistPlugin; constructor(app: App, plugin: ExistPlugin) { super(app, plugin); this.plugin = plugin; } display(): void { const { containerEl } = this; containerEl.empty(); containerEl.createEl("h2", { text: "Exist.io Sync" }); new Setting(containerEl) .setName("API token") .setDesc( createFragment((f) => { f.appendText("Your Exist.io personal access token. Get it at "); f.createEl("a", { text: "exist.io/account/api/", href: "https://exist.io/account/api/", }); }) ) .addText((text) => { text .setPlaceholder("Enter your token") .setValue(this.plugin.settings.existToken) .onChange(async (value) => { this.plugin.settings.existToken = value; await this.plugin.saveSettings(); }); text.inputEl.type = "password"; }); new Setting(containerEl) .setName("Sync on startup") .setDesc("Automatically sync yesterday's data when Obsidian opens.") .addToggle((toggle) => toggle .setValue(this.plugin.settings.syncOnStartup) .onChange(async (value) => { this.plugin.settings.syncOnStartup = value; await this.plugin.saveSettings(); }) ); } } ``` - [ ] **Step 2: Verify it type-checks** Run: `npx tsc --noEmit` Expected: Error about `ExistPlugin` not found in `./main` — this is expected since `main.ts` doesn't exist yet. All other files should type-check cleanly. - [ ] **Step 3: Commit** ```bash git add src/settings.ts git commit -m "feat: settings schema and settings tab UI" ``` --- ## Task 6: Plugin Entry Point (`src/main.ts`) **Files:** - Create: `src/main.ts` The plugin class, backfill modal, and all Obsidian lifecycle hooks. This wires together all other modules. - [ ] **Step 1: Create `src/main.ts`** ```typescript import { App, Modal, moment, Notice, Platform, Plugin } from "obsidian"; import { ExistApiError, ExistData, fetchRange } from "./api"; import { getDailyNotePath } from "./daily-notes"; import { updateNote } from "./notes"; import { DEFAULT_SETTINGS, ExistSettings, ExistSettingTab } from "./settings"; export default class ExistPlugin extends Plugin { settings: ExistSettings; private statusBarItem: HTMLElement | null = null; async onload(): Promise { await this.loadSettings(); // Ribbon icon — triggers manual sync this.addRibbonIcon("calendar-sync", "Sync Exist.io data", () => { this.syncYesterday(true); }); // Status bar item (desktop only) if (!Platform.isMobile) { this.statusBarItem = this.addStatusBarItem(); this.statusBarItem.style.cursor = "pointer"; this.statusBarItem.addEventListener("click", () => this.syncYesterday(true)); this.updateStatusBar(false); } // Command: sync yesterday this.addCommand({ id: "sync-exist", name: "Sync Exist.io data", callback: () => this.syncYesterday(true), }); // Command: backfill N days this.addCommand({ id: "backfill-exist", name: "Backfill Exist.io data", callback: () => new BackfillModal(this.app, this).open(), }); this.addSettingTab(new ExistSettingTab(this.app, this)); // Startup sync (silent) if (this.settings.syncOnStartup && this.settings.existToken) { this.syncYesterday(false); } } async loadSettings(): Promise { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); } async saveSettings(): Promise { await this.saveData(this.settings); } updateStatusBar(error: boolean): void { if (!this.statusBarItem) return; if (error) { this.statusBarItem.setText("Exist: error"); } else if (!this.settings.lastSyncedDate) { this.statusBarItem.setText("Exist: never"); } else { this.statusBarItem.setText(`Exist: ${this.settings.lastSyncedDate}`); } } /** ISO date string for yesterday */ private yesterday(): string { return moment().subtract(1, "day").format("YYYY-MM-DD"); } /** ISO date string for N days before today */ private dateMinusN(n: number): string { return moment().subtract(n, "days").format("YYYY-MM-DD"); } /** * Sync yesterday's data. * @param showSuccess - if true, show a notice on success; if false (startup), stay silent. */ async syncYesterday(showSuccess: boolean): Promise { if (!this.settings.existToken) { new Notice("Exist.io: no token configured. Open plugin settings."); return; } const date = this.yesterday(); try { const notePath = getDailyNotePath(this.app, date); const data = await fetchRange(this.settings.existToken, date, date); if (data[date]) { await updateNote(this.app, data[date], notePath); } else { console.log(`Exist.io: no data for ${date}`); } this.settings.lastSyncedDate = date; await this.saveSettings(); this.updateStatusBar(false); if (showSuccess) new Notice("Exist.io synced"); } catch (e) { this.handleError(e); } } /** * Sync the last `days` days, processing newest-first. * Called from BackfillModal after the user confirms. */ async backfillDays(days: number): Promise { if (!this.settings.existToken) { new Notice("Exist.io: no token configured. Open plugin settings."); return; } const clamped = Math.max(1, Math.min(31, days)); const dateMax = this.yesterday(); // yesterday const dateMin = this.dateMinusN(clamped); // N days before today // Dates in newest-first order (yesterday, day before, ..., dateMin) const dates: string[] = []; for (let i = 1; i <= clamped; i++) { dates.push(this.dateMinusN(i)); } const notice = new Notice(`Exist.io: syncing 0/${clamped}…`, 0); try { const allData = await fetchRange(this.settings.existToken, dateMin, dateMax); for (let i = 0; i < dates.length; i++) { const date = dates[i]; notice.setMessage(`Exist.io: syncing ${i + 1}/${clamped}…`); try { const notePath = getDailyNotePath(this.app, date); if (allData[date]) { await updateNote(this.app, allData[date], notePath); } else { console.log(`Exist.io: no data for ${date}`); } // Update lastSyncedDate if this date is newer than what's stored. // Since we process newest-first, the first day (yesterday) sets the value; // older days don't overwrite it. This ensures the status bar always shows // the newest synced date and progress is preserved if interrupted. if (!this.settings.lastSyncedDate || date > this.settings.lastSyncedDate) { this.settings.lastSyncedDate = date; } await this.saveSettings(); this.updateStatusBar(false); } catch (innerErr) { console.error(`Exist.io: error writing ${date}`, innerErr); } } notice.hide(); new Notice(`Exist.io: backfill complete (${clamped} days)`); } catch (e) { notice.hide(); this.handleError(e); } } private handleError(e: unknown): void { this.updateStatusBar(true); if (e instanceof ExistApiError) { if (e.status === 401) { new Notice("Exist.io: invalid token. Check plugin settings."); } else { new Notice(`Exist.io: API error ${e.status}.`); } } else if (e instanceof Error && e.message === "daily-notes-not-configured") { new Notice( "Obsidian Exist: Daily Notes or Periodic Notes plugin required. Please enable one and configure it." ); } else { new Notice("Exist.io: network error. Check your connection."); console.error(e); } } } // --------------------------------------------------------------------------- // Backfill Modal // --------------------------------------------------------------------------- class BackfillModal extends Modal { private plugin: ExistPlugin; constructor(app: App, plugin: ExistPlugin) { super(app); this.plugin = plugin; } onOpen(): void { const { contentEl } = this; contentEl.createEl("h2", { text: "Backfill Exist.io data" }); contentEl.createEl("p", { text: "How many days back? (1–31)" }); const input = contentEl.createEl("input"); input.type = "number"; input.min = "1"; input.max = "31"; input.placeholder = "7"; input.style.width = "100%"; input.style.marginBottom = "1em"; const buttonRow = contentEl.createDiv({ cls: "modal-button-container" }); buttonRow.createEl("button", { text: "Cancel" }).addEventListener("click", () => { this.close(); }); const okBtn = buttonRow.createEl("button", { text: "Sync", cls: "mod-cta" }); okBtn.addEventListener("click", () => { const raw = input.value.trim(); if (!raw) { this.close(); return; } const n = Math.max(1, Math.min(31, parseInt(raw, 10) || 1)); this.close(); this.plugin.backfillDays(n); }); input.focus(); input.addEventListener("keydown", (e: KeyboardEvent) => { if (e.key === "Enter") okBtn.click(); if (e.key === "Escape") this.close(); }); } onClose(): void { this.contentEl.empty(); } } ``` - [ ] **Step 2: Verify everything type-checks** Run: `npx tsc --noEmit` Expected: No errors. If there are errors, fix them before continuing. - [ ] **Step 3: Commit** ```bash git add src/main.ts git commit -m "feat: plugin entry point, commands, ribbon, status bar, backfill modal" ``` --- ## Task 7: Build and Install for Manual Testing **Files:** - No new files; builds `main.js` from sources. Manual testing is done against a real Obsidian vault with a real Exist.io token. No automated tests. - [ ] **Step 1: Build the plugin** Run: `npm run build` Expected: `main.js` created in the project root with no build errors. - [ ] **Step 2: Install the plugin into your Obsidian vault** The plugin must live at `.obsidian/plugins/obsidian-exist/` inside your vault. Three files are required: - `main.js` — the built bundle - `manifest.json` — plugin metadata - (optional) `styles.css` — not needed for this plugin ```bash # Set VAULT_PATH to your Obsidian vault root, e.g.: VAULT_PATH="$HOME/path/to/your/vault" PLUGIN_DIR="$VAULT_PATH/.obsidian/plugins/obsidian-exist" mkdir -p "$PLUGIN_DIR" cp main.js "$PLUGIN_DIR/" cp manifest.json "$PLUGIN_DIR/" ``` Alternatively, create a symlink so you don't need to copy after every build: ```bash ln -s "$(pwd)" "$VAULT_PATH/.obsidian/plugins/obsidian-exist" ``` - [ ] **Step 3: Enable the plugin in Obsidian** 1. Open Obsidian → Settings → Community Plugins 2. Disable Safe Mode if prompted 3. Find "Exist" in the list and enable it 4. Open the Exist plugin settings and enter your API token - [ ] **Step 4: Verify group names from the API** On first run, open the developer console (Ctrl+Shift+I / Cmd+Option+I) and add a temporary log to `src/api.ts` in the attribute loop: ```typescript // Add temporarily in the attrItems loop, after reading groupObj: console.log("group.name:", group, "group.label:", groupLabel); ``` Rebuild, reload Obsidian, trigger a sync, then check the console output. If any multi-word group names differ from the list in `src/notes.ts` (e.g. `"food_and_drink"` instead of `"food and drink"`), update `GROUP_ORDER` in `src/notes.ts` accordingly. Remove the temporary log and rebuild when done. - [ ] **Step 5: Manual test — sync yesterday** 1. Trigger "Sync Exist.io data" from the command palette 2. Open yesterday's daily note — verify: - `## Exist` section is present - Attributes are grouped with `### GroupLabel` headings - `mood:: N` appears (if mood was tracked) - `mood_note` appears as a blockquote (if set) - `Tags::` line appears in `### Custom` (if any boolean custom attrs are active) - `### Insights` section appears (if insights exist) - Frontmatter contains `mood: N` and `exist_tags: [...]` 3. Run sync again — verify the output is identical (idempotency check) 4. Check the status bar (desktop): should show `Exist: YYYY-MM-DD` - [ ] **Step 6: Manual test — backfill** 1. Trigger "Backfill Exist.io data" from the command palette 2. Enter `3` in the modal, click Sync 3. Verify the progress notice updates ("1/3", "2/3", "3/3") 4. Verify three daily notes are created/updated 5. Verify status bar shows the most recently synced date - [ ] **Step 7: Manual test — error handling** 1. In plugin settings, replace the token with `invalid` 2. Trigger sync — verify the notice says "invalid token. Check plugin settings." 3. Restore the real token - [ ] **Step 8: Manual test — new note creation** 1. Delete a daily note that was just synced (or choose a past date with no note) 2. Trigger a backfill that includes that date 3. Verify the note is created with default frontmatter (`created:`, `up: "[[Calendar]]"`) plus the Exist section - [ ] **Step 9: Final build and commit** ```bash npm run build git add src/main.ts # if any fixes were made git commit -m "fix: verified group names and manual testing complete" ``` --- ## Task 8: Version and Release Prep **Files:** - Modify: `manifest.json` (confirm version) - Modify: `package.json` (confirm version) - [ ] **Step 1: Confirm versions match** Both `manifest.json` and `package.json` should have `"version": "1.0.0"`. Verify they match. - [ ] **Step 2: Final build** Run: `npm run build` Expected: Clean build, no TypeScript errors. - [ ] **Step 3: Commit** ```bash git add manifest.json package.json git commit -m "chore: version 1.0.0" ``` --- ## Notes for the Implementer **Mobile testing:** If you need to test on iOS, use [Obsidian Sync](https://obsidian.md/sync) or copy the plugin folder to your iOS vault via Files app. The plugin uses `requestUrl` throughout — it will not work if you accidentally import `node:https` or `node:fs`. **Moment.js:** Import from `"obsidian"`, not from npm. Obsidian bundles moment globally. Using `window.moment` also works as a fallback. **Plugin reloading during dev:** After rebuilding, use the "Reload app without saving" command in Obsidian (or install the [Hot-Reload plugin](https://github.com/pjeby/hot-reload)) instead of restarting Obsidian each time. **Frontmatter key order:** `stringifyYaml` from Obsidian may reorder keys alphabetically. This is acceptable per the spec. If key order matters (it shouldn't), investigate `js-yaml` as an alternative (would need to be bundled).