1193 lines
35 KiB
Markdown
1193 lines
35 KiB
Markdown
# 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`**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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<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**
|
||
|
||
```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).
|