Files
obsidian-exist/docs/superpowers/plans/2026-03-27-obsidian-exist-plugin.md
svemagie d378ccd8e2 fresh
2026-03-31 14:19:59 +02:00

35 KiB
Raw Permalink Blame History

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.tsmain.js
.gitignore Ignore node_modules/, main.js, .obsidian/
src/api.ts fetchRange() — calls Exist.io API, paginates, returns Record<string, ExistData>
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

{
  "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
{
  "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
{
  "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
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
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
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<string, AttrValue>; // 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<Record<string, ExistData>> {
  const headers = { Authorization: `Bearer ${token}` };
  const results: Record<string, ExistData> = {};

  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<string, unknown>[]) {
    const vtype = attrObj["value_type"] as number;
    const groupObj = (attrObj["group"] ?? {}) as Record<string, string>;
    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<string, unknown>[]) {
    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<string, ExistData>, date: string): void {
  if (!results[date]) {
    results[date] = { date, attrs: {}, tags: [], insights: [] };
  }
}

async function paginate(
  firstUrl: string,
  headers: Record<string, string>
): Promise<unknown[]> {
  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
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
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<string, Array<[string, AttrValue]>> = {};
  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<string, unknown>;
  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<string, unknown>) ?? {};
      const body = content.slice(endIdx + 5); // skip "\n---\n"
      return { frontmatter: fm, body };
    }
  }
  return { frontmatter: {}, body: content };
}

function serializeNote(frontmatter: Record<string, unknown>, 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<void> {
  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<void> {
  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<string, unknown> = {
      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
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
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
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
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
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
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<void> {
    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<void> {
    this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
  }

  async saveSettings(): Promise<void> {
    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<void> {
    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<void> {
    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? (131)" });

    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
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
# 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:

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:

// 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
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
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 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) 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).