35 KiB
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<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.tswith types andfetchRange
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? (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
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.jsfrom 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 bundlemanifest.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
- Open Obsidian → Settings → Community Plugins
- Disable Safe Mode if prompted
- Find "Exist" in the list and enable it
- 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
- Trigger "Sync Exist.io data" from the command palette
- Open yesterday's daily note — verify:
## Existsection is present- Attributes are grouped with
### GroupLabelheadings mood:: Nappears (if mood was tracked)mood_noteappears as a blockquote (if set)Tags::line appears in### Custom(if any boolean custom attrs are active)### Insightssection appears (if insights exist)- Frontmatter contains
mood: Nandexist_tags: [...]
- Run sync again — verify the output is identical (idempotency check)
- Check the status bar (desktop): should show
Exist: YYYY-MM-DD
- Step 6: Manual test — backfill
- Trigger "Backfill Exist.io data" from the command palette
- Enter
3in the modal, click Sync - Verify the progress notice updates ("1/3", "2/3", "3/3")
- Verify three daily notes are created/updated
- Verify status bar shows the most recently synced date
- Step 7: Manual test — error handling
- In plugin settings, replace the token with
invalid - Trigger sync — verify the notice says "invalid token. Check plugin settings."
- Restore the real token
- Step 8: Manual test — new note creation
- Delete a daily note that was just synced (or choose a past date with no note)
- Trigger a backfill that includes that date
- 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).