commit d378ccd8e215edf452122a9cb00b26e7d6f990a5 Author: svemagie <869694+svemagie@users.noreply.github.com> Date: Tue Mar 31 14:19:59 2026 +0200 fresh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bcad2d --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +main.js +*.js.map +.obsidian/ diff --git a/docs/superpowers/plans/2026-03-27-obsidian-exist-plugin.md b/docs/superpowers/plans/2026-03-27-obsidian-exist-plugin.md new file mode 100644 index 0000000..07f601e --- /dev/null +++ b/docs/superpowers/plans/2026-03-27-obsidian-exist-plugin.md @@ -0,0 +1,1192 @@ +# Obsidian Exist Plugin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a native Obsidian plugin (TypeScript) that syncs Exist.io personal tracking data into daily notes, replacing the Python `exist-client` CLI tool. + +**Architecture:** Five focused modules — `api.ts` (Exist.io API client), `notes.ts` (note rendering and file I/O), `daily-notes.ts` (path resolution from Daily Notes / Periodic Notes plugin), `settings.ts` (settings UI), `main.ts` (plugin entry point, commands, ribbon, status bar). No external runtime npm dependencies; all HTTP via Obsidian's `requestUrl` for mobile compatibility. + +**Tech Stack:** TypeScript, Obsidian Plugin API, esbuild (bundler), obsidian npm package (types + API) + +**Spec:** `docs/superpowers/specs/2026-03-27-obsidian-exist-plugin-design.md` + +--- + +## File Map + +| File | Purpose | +|------|---------| +| `manifest.json` | Obsidian plugin manifest (id, version, minAppVersion) | +| `package.json` | npm scripts and dev dependencies | +| `tsconfig.json` | TypeScript compiler config | +| `esbuild.config.mjs` | Build script: bundles `src/main.ts` → `main.js` | +| `.gitignore` | Ignore `node_modules/`, `main.js`, `.obsidian/` | +| `src/api.ts` | `fetchRange()` — calls Exist.io API, paginates, returns `Record` | +| `src/notes.ts` | `renderExistSection()`, `replaceExistSection()`, `updateNote()` — all note logic | +| `src/daily-notes.ts` | `getDailyNotePath()` — reads Daily Notes / Periodic Notes plugin config | +| `src/settings.ts` | `ExistSettings` interface, `DEFAULT_SETTINGS`, `ExistSettingTab` class | +| `src/main.ts` | `ExistPlugin` class, `BackfillModal` class | + +--- + +## Task 1: Project Scaffolding + +**Files:** +- Create: `manifest.json` +- Create: `package.json` +- Create: `tsconfig.json` +- Create: `esbuild.config.mjs` +- Create: `.gitignore` + +- [ ] **Step 1: Create `manifest.json`** + +```json +{ + "id": "obsidian-exist", + "name": "Exist", + "version": "1.0.0", + "minAppVersion": "0.15.0", + "description": "Sync Exist.io personal data into your daily notes", + "author": "Sven Giersig", + "authorUrl": "", + "isDesktopOnly": false +} +``` + +- [ ] **Step 2: Create `package.json`** + +```json +{ + "name": "obsidian-exist", + "version": "1.0.0", + "description": "Sync Exist.io data into Obsidian daily notes", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "node esbuild.config.mjs production" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@types/node": "^16.11.6", + "builtin-modules": "^3.3.0", + "esbuild": "0.17.3", + "obsidian": "latest", + "tslib": "2.4.0", + "typescript": "4.7.4" + } +} +``` + +- [ ] **Step 3: Create `tsconfig.json`** + +```json +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowSyntheticDefaultImports": true, + "moduleResolution": "bundler", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": ["DOM", "ES5", "ES6", "ES7"] + }, + "include": ["src/**/*.ts"] +} +``` + +- [ ] **Step 4: Create `esbuild.config.mjs`** + +```js +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} +``` + +- [ ] **Step 5: Create `src/` directory and `.gitignore`** + +``` +node_modules/ +main.js +*.js.map +.obsidian/ +``` + +Run: `mkdir src` + +- [ ] **Step 6: Install dependencies** + +Run: `npm install` + +Expected: `node_modules/` created, no errors. + +- [ ] **Step 7: Commit** + +```bash +git add manifest.json package.json package-lock.json tsconfig.json esbuild.config.mjs .gitignore +git commit -m "chore: project scaffolding" +``` + +--- + +## Task 2: API Client (`src/api.ts`) + +**Files:** +- Create: `src/api.ts` + +This module fetches Exist.io data for a date range and returns it as a map of `date → ExistData`. All HTTP is done via Obsidian's `requestUrl` (mobile-compatible). No Node.js built-ins. + +- [ ] **Step 1: Create `src/api.ts` with types and `fetchRange`** + +```typescript +import { requestUrl } from "obsidian"; + +const BASE_URL = "https://exist.io/api/2"; + +export interface AttrValue { + value: unknown; + valueType: number; + label: string; + group: string; + groupLabel: string; +} + +export interface ExistData { + date: string; // ISO date, e.g. "2026-03-26" + attrs: Record; // keyed by attribute name, e.g. "mood", "steps" + tags: string[]; // labels of active boolean custom attributes + insights: string[]; // insight text strings +} + +export class ExistApiError extends Error { + constructor(public status: number, public url: string) { + super(`Exist API error ${status} at ${url}`); + } +} + +/** + * Fetch all Exist.io data for a date range (max 31 days). + * dateMin and dateMax are ISO date strings (YYYY-MM-DD). + * Returns a map of ISO date string → ExistData. + */ +export async function fetchRange( + token: string, + dateMin: string, + dateMax: string +): Promise> { + const headers = { Authorization: `Bearer ${token}` }; + const results: Record = {}; + + const daysCount = daysBetween(dateMin, dateMax) + 1; + + // Fetch attributes with values + const attrUrl = new URL(`${BASE_URL}/attributes/with-values/`); + attrUrl.searchParams.set("date_max", dateMax); + attrUrl.searchParams.set("days", String(daysCount)); + attrUrl.searchParams.set("limit", "100"); + + const attrItems = await paginate(attrUrl.toString(), headers); + + for (const attrObj of attrItems as Record[]) { + const vtype = attrObj["value_type"] as number; + const groupObj = (attrObj["group"] ?? {}) as Record; + const group = groupObj["name"] ?? ""; + const groupLabel = groupObj["label"] ?? group; + const label = attrObj["label"] as string; + const name = attrObj["name"] as string; + + for (const v of (attrObj["values"] ?? []) as Array<{ value: unknown; date: string }>) { + if (v.value === null || v.value === undefined) continue; + const d = v.date; + + if (vtype === 7) { + // Boolean attribute: only active (value=1) custom group attrs become tags + if (group === "custom" && v.value === 1) { + ensure(results, d); + results[d].tags.push(label); + } + // All other boolean attrs are dropped + } else { + ensure(results, d); + results[d].attrs[name] = { + value: v.value, + valueType: vtype, + label, + group, + groupLabel, + }; + } + } + } + + // Fetch insights + const insightUrl = new URL(`${BASE_URL}/insights/`); + insightUrl.searchParams.set("date_min", dateMin); + insightUrl.searchParams.set("date_max", dateMax); + insightUrl.searchParams.set("limit", "100"); + + const insightItems = await paginate(insightUrl.toString(), headers); + + for (const insight of insightItems as Record[]) { + const target = insight["target_date"] as string; + const text = insight["text"] as string; + if (!target || !text) continue; + if (target < dateMin || target > dateMax) continue; + ensure(results, target); + results[target].insights.push(text); + } + + return results; +} + +function ensure(results: Record, date: string): void { + if (!results[date]) { + results[date] = { date, attrs: {}, tags: [], insights: [] }; + } +} + +async function paginate( + firstUrl: string, + headers: Record +): Promise { + const items: unknown[] = []; + let url: string | null = firstUrl; + + while (url) { + const resp = await requestUrl({ url, headers }); + if (resp.status !== 200) { + throw new ExistApiError(resp.status, url); + } + const body = resp.json as { results?: unknown[]; next?: string | null }; + items.push(...(body.results ?? [])); + url = body.next ?? null; + } + + return items; +} + +function daysBetween(dateMin: string, dateMax: string): number { + const d1 = new Date(dateMin).getTime(); + const d2 = new Date(dateMax).getTime(); + return Math.round((d2 - d1) / (1000 * 60 * 60 * 24)); +} +``` + +- [ ] **Step 2: Verify it type-checks** + +Run: `npx tsc --noEmit` + +Expected: No errors (or only errors from files not yet created — that's fine at this stage). + +- [ ] **Step 3: Commit** + +```bash +git add src/api.ts +git commit -m "feat: Exist.io API client" +``` + +--- + +## Task 3: Note Rendering (`src/notes.ts`) + +**Files:** +- Create: `src/notes.ts` + +This module contains all note logic: value formatting, group ordering, section rendering, section replacement, frontmatter update, and file I/O. It has no side effects beyond reading/writing vault files. + +- [ ] **Step 1: Create `src/notes.ts`** + +```typescript +import { App, normalizePath, parseYaml, stringifyYaml, TFile } from "obsidian"; +import { AttrValue, ExistData } from "./api"; + +// Group render order by API short name (group.name field, not display label). +// IMPORTANT: On first development run, log raw group.name values from the API +// and verify these strings match. Multi-word group names (e.g. "food and drink") +// may use spaces or underscores in the actual API response. +const GROUP_ORDER = [ + "mood", "sleep", "activity", "workouts", "productivity", "health", + "food and drink", "finance", "events", "location", "media", "social", + "weather", "twitter", +]; + +// --- Value Formatting --- + +export function formatValue(value: unknown, valueType: number): string { + if (valueType === 0 || valueType === 8) { + // integer or scale + return String(Math.floor(Number(value))); + } + if (valueType === 1) { + // float: one decimal place + return Number(value).toFixed(1); + } + if (valueType === 3) { + // duration in minutes + const minutes = Math.floor(Number(value)); + if (minutes < 60) return `${minutes}m`; + const h = Math.floor(minutes / 60); + const m = minutes % 60; + return `${h}h ${m}m`; + } + if (valueType === 5) { + // percentage + return `${Number(value).toFixed(1)}%`; + } + // string, TimeOfDay, Period, unknown + return String(value); +} + +// --- Zero Omission --- + +function isZeroOmittable(name: string, value: unknown, valueType: number): boolean { + if (name === "mood") return false; // mood is never omitted + // integer (0), duration (3), percentage (5), scale (8): omit if value is 0 + if (valueType === 0 || valueType === 3 || valueType === 5 || valueType === 8) { + return value === 0; + } + // float, string, TimeOfDay, Period: 0 and "" are legitimate values, never omit + return false; +} + +// --- Group Sorting --- + +function groupSortKey(group: string): [number, string] { + const idx = GROUP_ORDER.indexOf(group); + return idx >= 0 ? [idx, group] : [GROUP_ORDER.length, group]; +} + +// --- Section Rendering --- + +/** + * Render the full ## Exist section as a string. + * The returned string always ends with exactly one \n. + */ +export function renderExistSection(data: ExistData): string { + const lines: string[] = ["## Exist"]; + + // Bucket non-zero attrs by group + const groups: Record> = {}; + for (const [name, av] of Object.entries(data.attrs)) { + if (isZeroOmittable(name, av.value, av.valueType)) continue; + if (!groups[av.group]) groups[av.group] = []; + groups[av.group].push([name, av]); + } + + // Render non-custom groups in predefined order + const nonCustomGroups = Object.keys(groups).filter((g) => g !== "custom"); + nonCustomGroups.sort((a, b) => { + const [ai, as_] = groupSortKey(a); + const [bi, bs] = groupSortKey(b); + return ai !== bi ? ai - bi : as_.localeCompare(bs); + }); + + for (const groupName of nonCustomGroups) { + const attrsInGroup = groups[groupName]; + const groupLabel = attrsInGroup[0][1].groupLabel; + lines.push("", `### ${groupLabel}`, ""); + + let moodNoteVal: string | null = null; + for (const [name, av] of attrsInGroup) { + if (name === "mood_note") { + // Deferred: rendered as blockquote at end of group + const val = String(av.value).trim(); + moodNoteVal = val || null; + continue; + } + lines.push(`${av.label}:: ${formatValue(av.value, av.valueType)}`); + } + if (moodNoteVal) { + lines.push("", `> ${moodNoteVal}`); + } + } + + // Insights subsection + if (data.insights.length > 0) { + lines.push("", "### Insights", ""); + for (const text of data.insights) { + lines.push(`> ${text}`); + } + } + + // Custom group: non-boolean custom attrs + tags line + const customAttrs = groups["custom"] ?? []; + if (customAttrs.length > 0 || data.tags.length > 0) { + lines.push("", "### Custom", ""); + for (const [, av] of customAttrs) { + lines.push(`${av.label}:: ${formatValue(av.value, av.valueType)}`); + } + if (data.tags.length > 0) { + lines.push(`Tags:: ${data.tags.join(", ")}`); + } + } + + return lines.join("\n") + "\n"; +} + +// --- Section Replacement --- + +/** + * Replace the ## Exist section in `content` with `newSection`. + * If no ## Exist section exists, appends to end. + * Preserves all other content exactly. + */ +export function replaceExistSection(content: string, newSection: string): string { + const lines = content.split("\n"); + + // Find ## Exist heading + let start = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trimEnd() === "## Exist") { + start = i; + break; + } + } + + if (start === -1) { + // Append to end + const stripped = content.trimEnd(); + if (!stripped) return newSection; + return stripped + "\n\n" + newSection; + } + + // Find end of section: next ## heading or EOF + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (lines[i].startsWith("## ")) { + end = i; + break; + } + } + + // Strip trailing blank lines from the old section range + while (end > start + 1 && lines[end - 1].trim() === "") { + end--; + } + + const before = lines.slice(0, start); + let after = lines.slice(end); + + // Strip leading blank lines from after + while (after.length > 0 && after[0].trim() === "") { + after = after.slice(1); + } + + // Rebuild: before + newSection + blank separator + after (if any) + const beforeStr = before.length > 0 ? before.join("\n") + "\n" : ""; + if (after.length > 0) { + return beforeStr + newSection + "\n" + after.join("\n"); + } + return beforeStr + newSection; +} + +// --- Frontmatter --- + +interface ParsedNote { + frontmatter: Record; + body: string; +} + +function parseNote(content: string): ParsedNote { + if (content.startsWith("---\n")) { + const endIdx = content.indexOf("\n---\n", 4); + if (endIdx !== -1) { + const yamlStr = content.slice(4, endIdx); + const fm = (parseYaml(yamlStr) as Record) ?? {}; + const body = content.slice(endIdx + 5); // skip "\n---\n" + return { frontmatter: fm, body }; + } + } + return { frontmatter: {}, body: content }; +} + +function serializeNote(frontmatter: Record, body: string): string { + return `---\n${stringifyYaml(frontmatter)}---\n${body}`; +} + +// --- File I/O --- + +/** + * Create all ancestor directories of a vault path. + * vault.create() does not create parents automatically on mobile. + */ +async function ensureParentDirs(app: App, filePath: string): Promise { + const dir = filePath.substring(0, filePath.lastIndexOf("/")); + if (!dir) return; + const parts = dir.split("/").filter(Boolean); + let current = ""; + for (const part of parts) { + current = current ? `${current}/${part}` : part; + if (!app.vault.getAbstractFileByPath(current)) { + try { + await app.vault.adapter.mkdir(normalizePath(current)); + } catch { + // Already exists — safe to ignore + } + } + } +} + +/** + * Update (or create) the daily note at `filePath` with Exist data. + * Idempotent: re-running with the same data produces identical output. + */ +export async function updateNote( + app: App, + data: ExistData, + filePath: string +): Promise { + const normalized = normalizePath(filePath); + let content: string; + let tfile: TFile; + + const existing = app.vault.getAbstractFileByPath(normalized); + + if (existing instanceof TFile) { + tfile = existing; + content = await app.vault.read(tfile); + } else { + // Create new note with default frontmatter + await ensureParentDirs(app, normalized); + const defaultFm: Record = { + created: data.date, + up: "[[Calendar]]", + }; + const initialContent = `---\n${stringifyYaml(defaultFm)}---\n`; + tfile = await app.vault.create(normalized, initialContent); + content = initialContent; + } + + // Parse frontmatter and body + const { frontmatter: fm, body } = parseNote(content); + + // Update frontmatter keys + fm.exist_tags = data.tags; + const moodAttr = data.attrs["mood"]; + if (moodAttr !== undefined) { + fm.mood = Number(moodAttr.value); + } + + // Update Exist section in body + const section = renderExistSection(data); + const newBody = replaceExistSection(body, section); + + // Write back + await app.vault.modify(tfile, serializeNote(fm, newBody)); +} +``` + +- [ ] **Step 2: Verify it type-checks** + +Run: `npx tsc --noEmit` + +Expected: No errors from files created so far (api.ts, notes.ts). + +- [ ] **Step 3: Commit** + +```bash +git add src/notes.ts +git commit -m "feat: note rendering and file I/O" +``` + +--- + +## Task 4: Daily Notes Path Resolution (`src/daily-notes.ts`) + +**Files:** +- Create: `src/daily-notes.ts` + +This module reads the Daily Notes or Periodic Notes plugin's configured folder and date format, then constructs the vault-relative path for a given date. Periodic Notes takes precedence when both are active. + +- [ ] **Step 1: Create `src/daily-notes.ts`** + +```typescript +import { App, moment } from "obsidian"; + +/** + * Resolve the vault-relative file path for a daily note on `date` (ISO date string). + * Reads config from Periodic Notes (preferred) or Daily Notes core plugin. + * Throws with message "daily-notes-not-configured" if neither is available. + */ +export function getDailyNotePath(app: App, date: string): string { + const m = moment(date, "YYYY-MM-DD"); + + // Periodic Notes community plugin takes precedence + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const periodicNotes = (app as any).plugins?.plugins?.["periodic-notes"]; + if (periodicNotes?.settings?.daily) { + const { folder = "", format = "YYYY-MM-DD" } = periodicNotes.settings.daily as { + folder?: string; + format?: string; + }; + return buildPath(folder, m.format(format)); + } + + // Daily Notes core plugin + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const dailyNotes = (app as any).internalPlugins?.getPluginById?.("daily-notes"); + const options = dailyNotes?.instance?.options; + if (options !== undefined) { + const { folder = "", format = "YYYY-MM-DD" } = options as { + folder?: string; + format?: string; + }; + return buildPath(folder, m.format(format)); + } + + throw new Error("daily-notes-not-configured"); +} + +function buildPath(folder: string, formattedDate: string): string { + const cleanFolder = folder.replace(/\/$/, ""); // strip trailing slash + return cleanFolder ? `${cleanFolder}/${formattedDate}.md` : `${formattedDate}.md`; +} +``` + +- [ ] **Step 2: Verify it type-checks** + +Run: `npx tsc --noEmit` + +Expected: No errors from files created so far. + +- [ ] **Step 3: Commit** + +```bash +git add src/daily-notes.ts +git commit -m "feat: Daily Notes / Periodic Notes path resolution" +``` + +--- + +## Task 5: Settings (`src/settings.ts`) + +**Files:** +- Create: `src/settings.ts` + +Defines the settings schema and the Obsidian settings tab UI. The tab shows the API token (password-masked) and a sync-on-startup toggle. + +- [ ] **Step 1: Create `src/settings.ts`** + +```typescript +import { App, PluginSettingTab, Setting } from "obsidian"; +import type ExistPlugin from "./main"; + +export interface ExistSettings { + existToken: string; + syncOnStartup: boolean; + lastSyncedDate: string; // ISO date of most recently synced note; "" if never synced +} + +export const DEFAULT_SETTINGS: ExistSettings = { + existToken: "", + syncOnStartup: true, + lastSyncedDate: "", +}; + +export class ExistSettingTab extends PluginSettingTab { + plugin: ExistPlugin; + + constructor(app: App, plugin: ExistPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl("h2", { text: "Exist.io Sync" }); + + new Setting(containerEl) + .setName("API token") + .setDesc( + createFragment((f) => { + f.appendText("Your Exist.io personal access token. Get it at "); + f.createEl("a", { + text: "exist.io/account/api/", + href: "https://exist.io/account/api/", + }); + }) + ) + .addText((text) => { + text + .setPlaceholder("Enter your token") + .setValue(this.plugin.settings.existToken) + .onChange(async (value) => { + this.plugin.settings.existToken = value; + await this.plugin.saveSettings(); + }); + text.inputEl.type = "password"; + }); + + new Setting(containerEl) + .setName("Sync on startup") + .setDesc("Automatically sync yesterday's data when Obsidian opens.") + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.syncOnStartup) + .onChange(async (value) => { + this.plugin.settings.syncOnStartup = value; + await this.plugin.saveSettings(); + }) + ); + } +} +``` + +- [ ] **Step 2: Verify it type-checks** + +Run: `npx tsc --noEmit` + +Expected: Error about `ExistPlugin` not found in `./main` — this is expected since `main.ts` doesn't exist yet. All other files should type-check cleanly. + +- [ ] **Step 3: Commit** + +```bash +git add src/settings.ts +git commit -m "feat: settings schema and settings tab UI" +``` + +--- + +## Task 6: Plugin Entry Point (`src/main.ts`) + +**Files:** +- Create: `src/main.ts` + +The plugin class, backfill modal, and all Obsidian lifecycle hooks. This wires together all other modules. + +- [ ] **Step 1: Create `src/main.ts`** + +```typescript +import { App, Modal, moment, Notice, Platform, Plugin } from "obsidian"; +import { ExistApiError, ExistData, fetchRange } from "./api"; +import { getDailyNotePath } from "./daily-notes"; +import { updateNote } from "./notes"; +import { DEFAULT_SETTINGS, ExistSettings, ExistSettingTab } from "./settings"; + +export default class ExistPlugin extends Plugin { + settings: ExistSettings; + private statusBarItem: HTMLElement | null = null; + + async onload(): Promise { + await this.loadSettings(); + + // Ribbon icon — triggers manual sync + this.addRibbonIcon("calendar-sync", "Sync Exist.io data", () => { + this.syncYesterday(true); + }); + + // Status bar item (desktop only) + if (!Platform.isMobile) { + this.statusBarItem = this.addStatusBarItem(); + this.statusBarItem.style.cursor = "pointer"; + this.statusBarItem.addEventListener("click", () => this.syncYesterday(true)); + this.updateStatusBar(false); + } + + // Command: sync yesterday + this.addCommand({ + id: "sync-exist", + name: "Sync Exist.io data", + callback: () => this.syncYesterday(true), + }); + + // Command: backfill N days + this.addCommand({ + id: "backfill-exist", + name: "Backfill Exist.io data", + callback: () => new BackfillModal(this.app, this).open(), + }); + + this.addSettingTab(new ExistSettingTab(this.app, this)); + + // Startup sync (silent) + if (this.settings.syncOnStartup && this.settings.existToken) { + this.syncYesterday(false); + } + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + updateStatusBar(error: boolean): void { + if (!this.statusBarItem) return; + if (error) { + this.statusBarItem.setText("Exist: error"); + } else if (!this.settings.lastSyncedDate) { + this.statusBarItem.setText("Exist: never"); + } else { + this.statusBarItem.setText(`Exist: ${this.settings.lastSyncedDate}`); + } + } + + /** ISO date string for yesterday */ + private yesterday(): string { + return moment().subtract(1, "day").format("YYYY-MM-DD"); + } + + /** ISO date string for N days before today */ + private dateMinusN(n: number): string { + return moment().subtract(n, "days").format("YYYY-MM-DD"); + } + + /** + * Sync yesterday's data. + * @param showSuccess - if true, show a notice on success; if false (startup), stay silent. + */ + async syncYesterday(showSuccess: boolean): Promise { + if (!this.settings.existToken) { + new Notice("Exist.io: no token configured. Open plugin settings."); + return; + } + + const date = this.yesterday(); + + try { + const notePath = getDailyNotePath(this.app, date); + const data = await fetchRange(this.settings.existToken, date, date); + + if (data[date]) { + await updateNote(this.app, data[date], notePath); + } else { + console.log(`Exist.io: no data for ${date}`); + } + + this.settings.lastSyncedDate = date; + await this.saveSettings(); + this.updateStatusBar(false); + + if (showSuccess) new Notice("Exist.io synced"); + } catch (e) { + this.handleError(e); + } + } + + /** + * Sync the last `days` days, processing newest-first. + * Called from BackfillModal after the user confirms. + */ + async backfillDays(days: number): Promise { + if (!this.settings.existToken) { + new Notice("Exist.io: no token configured. Open plugin settings."); + return; + } + + const clamped = Math.max(1, Math.min(31, days)); + const dateMax = this.yesterday(); // yesterday + const dateMin = this.dateMinusN(clamped); // N days before today + + // Dates in newest-first order (yesterday, day before, ..., dateMin) + const dates: string[] = []; + for (let i = 1; i <= clamped; i++) { + dates.push(this.dateMinusN(i)); + } + + const notice = new Notice(`Exist.io: syncing 0/${clamped}…`, 0); + + try { + const allData = await fetchRange(this.settings.existToken, dateMin, dateMax); + + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + notice.setMessage(`Exist.io: syncing ${i + 1}/${clamped}…`); + + try { + const notePath = getDailyNotePath(this.app, date); + if (allData[date]) { + await updateNote(this.app, allData[date], notePath); + } else { + console.log(`Exist.io: no data for ${date}`); + } + // Update lastSyncedDate if this date is newer than what's stored. + // Since we process newest-first, the first day (yesterday) sets the value; + // older days don't overwrite it. This ensures the status bar always shows + // the newest synced date and progress is preserved if interrupted. + if (!this.settings.lastSyncedDate || date > this.settings.lastSyncedDate) { + this.settings.lastSyncedDate = date; + } + await this.saveSettings(); + this.updateStatusBar(false); + } catch (innerErr) { + console.error(`Exist.io: error writing ${date}`, innerErr); + } + } + + notice.hide(); + new Notice(`Exist.io: backfill complete (${clamped} days)`); + } catch (e) { + notice.hide(); + this.handleError(e); + } + } + + private handleError(e: unknown): void { + this.updateStatusBar(true); + if (e instanceof ExistApiError) { + if (e.status === 401) { + new Notice("Exist.io: invalid token. Check plugin settings."); + } else { + new Notice(`Exist.io: API error ${e.status}.`); + } + } else if (e instanceof Error && e.message === "daily-notes-not-configured") { + new Notice( + "Obsidian Exist: Daily Notes or Periodic Notes plugin required. Please enable one and configure it." + ); + } else { + new Notice("Exist.io: network error. Check your connection."); + console.error(e); + } + } +} + +// --------------------------------------------------------------------------- +// Backfill Modal +// --------------------------------------------------------------------------- + +class BackfillModal extends Modal { + private plugin: ExistPlugin; + + constructor(app: App, plugin: ExistPlugin) { + super(app); + this.plugin = plugin; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.createEl("h2", { text: "Backfill Exist.io data" }); + contentEl.createEl("p", { text: "How many days back? (1–31)" }); + + const input = contentEl.createEl("input"); + input.type = "number"; + input.min = "1"; + input.max = "31"; + input.placeholder = "7"; + input.style.width = "100%"; + input.style.marginBottom = "1em"; + + const buttonRow = contentEl.createDiv({ cls: "modal-button-container" }); + + buttonRow.createEl("button", { text: "Cancel" }).addEventListener("click", () => { + this.close(); + }); + + const okBtn = buttonRow.createEl("button", { text: "Sync", cls: "mod-cta" }); + okBtn.addEventListener("click", () => { + const raw = input.value.trim(); + if (!raw) { + this.close(); + return; + } + const n = Math.max(1, Math.min(31, parseInt(raw, 10) || 1)); + this.close(); + this.plugin.backfillDays(n); + }); + + input.focus(); + input.addEventListener("keydown", (e: KeyboardEvent) => { + if (e.key === "Enter") okBtn.click(); + if (e.key === "Escape") this.close(); + }); + } + + onClose(): void { + this.contentEl.empty(); + } +} +``` + +- [ ] **Step 2: Verify everything type-checks** + +Run: `npx tsc --noEmit` + +Expected: No errors. If there are errors, fix them before continuing. + +- [ ] **Step 3: Commit** + +```bash +git add src/main.ts +git commit -m "feat: plugin entry point, commands, ribbon, status bar, backfill modal" +``` + +--- + +## Task 7: Build and Install for Manual Testing + +**Files:** +- No new files; builds `main.js` from sources. + +Manual testing is done against a real Obsidian vault with a real Exist.io token. No automated tests. + +- [ ] **Step 1: Build the plugin** + +Run: `npm run build` + +Expected: `main.js` created in the project root with no build errors. + +- [ ] **Step 2: Install the plugin into your Obsidian vault** + +The plugin must live at `.obsidian/plugins/obsidian-exist/` inside your vault. Three files are required: +- `main.js` — the built bundle +- `manifest.json` — plugin metadata +- (optional) `styles.css` — not needed for this plugin + +```bash +# Set VAULT_PATH to your Obsidian vault root, e.g.: +VAULT_PATH="$HOME/path/to/your/vault" +PLUGIN_DIR="$VAULT_PATH/.obsidian/plugins/obsidian-exist" + +mkdir -p "$PLUGIN_DIR" +cp main.js "$PLUGIN_DIR/" +cp manifest.json "$PLUGIN_DIR/" +``` + +Alternatively, create a symlink so you don't need to copy after every build: + +```bash +ln -s "$(pwd)" "$VAULT_PATH/.obsidian/plugins/obsidian-exist" +``` + +- [ ] **Step 3: Enable the plugin in Obsidian** + +1. Open Obsidian → Settings → Community Plugins +2. Disable Safe Mode if prompted +3. Find "Exist" in the list and enable it +4. Open the Exist plugin settings and enter your API token + +- [ ] **Step 4: Verify group names from the API** + +On first run, open the developer console (Ctrl+Shift+I / Cmd+Option+I) and add a temporary log to `src/api.ts` in the attribute loop: + +```typescript +// Add temporarily in the attrItems loop, after reading groupObj: +console.log("group.name:", group, "group.label:", groupLabel); +``` + +Rebuild, reload Obsidian, trigger a sync, then check the console output. If any multi-word group names differ from the list in `src/notes.ts` (e.g. `"food_and_drink"` instead of `"food and drink"`), update `GROUP_ORDER` in `src/notes.ts` accordingly. + +Remove the temporary log and rebuild when done. + +- [ ] **Step 5: Manual test — sync yesterday** + +1. Trigger "Sync Exist.io data" from the command palette +2. Open yesterday's daily note — verify: + - `## Exist` section is present + - Attributes are grouped with `### GroupLabel` headings + - `mood:: N` appears (if mood was tracked) + - `mood_note` appears as a blockquote (if set) + - `Tags::` line appears in `### Custom` (if any boolean custom attrs are active) + - `### Insights` section appears (if insights exist) + - Frontmatter contains `mood: N` and `exist_tags: [...]` +3. Run sync again — verify the output is identical (idempotency check) +4. Check the status bar (desktop): should show `Exist: YYYY-MM-DD` + +- [ ] **Step 6: Manual test — backfill** + +1. Trigger "Backfill Exist.io data" from the command palette +2. Enter `3` in the modal, click Sync +3. Verify the progress notice updates ("1/3", "2/3", "3/3") +4. Verify three daily notes are created/updated +5. Verify status bar shows the most recently synced date + +- [ ] **Step 7: Manual test — error handling** + +1. In plugin settings, replace the token with `invalid` +2. Trigger sync — verify the notice says "invalid token. Check plugin settings." +3. Restore the real token + +- [ ] **Step 8: Manual test — new note creation** + +1. Delete a daily note that was just synced (or choose a past date with no note) +2. Trigger a backfill that includes that date +3. Verify the note is created with default frontmatter (`created:`, `up: "[[Calendar]]"`) plus the Exist section + +- [ ] **Step 9: Final build and commit** + +```bash +npm run build +git add src/main.ts # if any fixes were made +git commit -m "fix: verified group names and manual testing complete" +``` + +--- + +## Task 8: Version and Release Prep + +**Files:** +- Modify: `manifest.json` (confirm version) +- Modify: `package.json` (confirm version) + +- [ ] **Step 1: Confirm versions match** + +Both `manifest.json` and `package.json` should have `"version": "1.0.0"`. Verify they match. + +- [ ] **Step 2: Final build** + +Run: `npm run build` + +Expected: Clean build, no TypeScript errors. + +- [ ] **Step 3: Commit** + +```bash +git add manifest.json package.json +git commit -m "chore: version 1.0.0" +``` + +--- + +## Notes for the Implementer + +**Mobile testing:** If you need to test on iOS, use [Obsidian Sync](https://obsidian.md/sync) or copy the plugin folder to your iOS vault via Files app. The plugin uses `requestUrl` throughout — it will not work if you accidentally import `node:https` or `node:fs`. + +**Moment.js:** Import from `"obsidian"`, not from npm. Obsidian bundles moment globally. Using `window.moment` also works as a fallback. + +**Plugin reloading during dev:** After rebuilding, use the "Reload app without saving" command in Obsidian (or install the [Hot-Reload plugin](https://github.com/pjeby/hot-reload)) instead of restarting Obsidian each time. + +**Frontmatter key order:** `stringifyYaml` from Obsidian may reorder keys alphabetically. This is acceptable per the spec. If key order matters (it shouldn't), investigate `js-yaml` as an alternative (would need to be bundled). diff --git a/docs/superpowers/specs/2026-03-27-obsidian-exist-plugin-design.md b/docs/superpowers/specs/2026-03-27-obsidian-exist-plugin-design.md new file mode 100644 index 0000000..5060138 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-obsidian-exist-plugin-design.md @@ -0,0 +1,229 @@ +# Obsidian Exist Plugin — Design Spec + +**Date:** 2026-03-27 +**Status:** Approved + +## Overview + +A native Obsidian plugin (TypeScript) that syncs Exist.io personal tracking data into Obsidian daily notes. Replaces the existing Python CLI tool (`exist-client`) with an in-app experience that works on both desktop and mobile. + +## Goals + +- Sync Exist.io attribute data, insights, and tags into daily notes automatically on startup +- Provide manual sync and backfill commands +- Produce identical note output to the Python `exist-client` tool +- Work on desktop and mobile (iOS/Android) +- Feel like a polished, native Obsidian plugin + +## Non-Goals + +- Configurable attribute group visibility (can be added later) +- Writing data back to Exist.io +- Any UI beyond settings tab, ribbon icon, status bar, and notices +- Rate limiting or retry logic for API calls (deferred) +- Automated tests (manual testing against real API and vault) + +--- + +## Architecture + +The plugin is structured as five focused modules: + +``` +obsidian-exist/ +├── src/ +│ ├── main.ts — Plugin entry point, lifecycle, commands, ribbon, status bar +│ ├── api.ts — Exist.io API client +│ ├── notes.ts — Note rendering, section replacement, frontmatter +│ ├── settings.ts — Settings tab UI and defaults +│ └── daily-notes.ts — Daily Notes / Periodic Notes plugin path resolution +├── manifest.json +├── package.json +├── esbuild.config.mjs +└── tsconfig.json +``` + +**Data flow:** On startup (and on manual trigger), `main.ts` resolves the target date(s), calls `api.ts` to fetch Exist data, then calls `notes.ts` to render and write each note. File paths are resolved at sync time via `daily-notes.ts`. + +--- + +## Module Designs + +### `api.ts` — Exist.io API Client + +- Uses Obsidian's `requestUrl` for all HTTP calls (required for mobile compatibility; never use `fetch` or Node built-ins) +- Bearer token authentication via `Authorization: Bearer ` header +- Base URL: `https://exist.io/api/2` +- Fetches from two endpoints: + - `attributes/with-values/` — query params: `date_max` (ISO date of most recent day), `days` (count of days in range), `limit: 100` + - `insights/` — query params: `date_min` (ISO date), `date_max` (ISO date), `limit: 100` +- Handles pagination by following the `next` URL in the response body (only the first request sends query params; subsequent pagination requests follow `next` as-is) +- Maximum 31-day range — backfill input is clamped to 31 + +**Data types:** +- `AttrValue`: `{ value: any, valueType: number, label: string, group: string, groupLabel: string }` +- `ExistData`: `{ date: string, attrs: Record, tags: string[], insights: string[] }` +- The `attrs` map key is the attribute `name` field from the API response (e.g. `"mood"`, `"steps"`) + +**Tag data source:** `ExistData.tags` is populated exclusively from boolean custom attributes — there is no separate tags endpoint. Non-custom boolean attributes do not produce tags. + +**Boolean-to-tag conversion** — handled entirely in `api.ts`, not in `notes.ts`: +- Attribute `valueType === 7` (boolean) AND `group === "custom"` AND `value === 1` → label is added to `ExistData.tags` +- These attributes are never stored in `attrs` +- All other valueType 7 attributes (non-custom) are stored in `attrs` normally + +**Insight shape:** Each insight object from the API has `target_date` (ISO date string) and `text` (string). Only the `text` field is stored in `ExistData.insights`. + +--- + +### `notes.ts` — Note Rendering & File I/O + +Identical logic to Python `notes.py`. + +**Value formatting** (by `valueType`): +- `0` (integer) / `8` (scale): `str(int(value))` → e.g. `8432` +- `1` (float): one decimal place → e.g. `6.3` +- `3` (duration, in minutes): `7h 12m`; if under 60 minutes: `45m` +- `5` (percentage): one decimal place + `%` → e.g. `23.4%` +- `2` (string), `4` (TimeOfDay), `6` (Period), unknown: `str(value)` → e.g. `Berlin`, `23:15` + +**Zero-omission rules:** +- Omit if `valueType` is `0`, `3`, `5`, or `8` (integer, duration, percentage, scale) AND value equals `0` +- Exception: `mood` (by attribute name) is never omitted regardless of value +- `valueType 1` (float): zero is a legitimate value — never omitted +- `valueType 2` (string): empty string is a legitimate value — never omitted + +**Attribute grouping:** Rendered in this order by group `name` (short name from the API response `group.name` field, not the display label): +``` +["mood", "sleep", "activity", "workouts", "productivity", "health", + "food and drink", "finance", "events", "location", "media", "social", + "weather", "twitter"] +``` +This matches the Python `exist-client` source. Unknown groups fall after `"twitter"`, sorted alphabetically. + +**Implementation note:** On the first development run, log the raw `group.name` values from the API response to verify the exact strings for multi-word groups (e.g. `"food and drink"` vs `"food_and_drink"`). Update the array if they differ from the list above. The ordering comparison is a literal string match. + +**Group rendering:** Each group gets a `### GroupLabel` heading (using the `groupLabel` from the API). The `custom` group is handled specially (see below). + +**`mood_note` rendering:** Within the Mood group, `mood_note` is deferred and rendered as a blockquote at the end of the group, after all other mood attributes, separated by a blank line: `> text`. If the value is blank/whitespace, it is omitted entirely. + +**Custom group:** Non-boolean custom attributes and tags are rendered together under `### Custom`. Non-boolean custom attrs appear as Dataview-style inline fields (`Label:: value`) — same format as all other attributes. Boolean custom attrs (converted to tags) appear as `Tags:: tag1, tag2` at the end of the section. + +**Insights:** Rendered under `### Insights` as blockquotes: `> insight text`. One line per insight. + +**Section replacement:** +1. Find the line `## Exist` in the note content +2. Find the end of the section: next `## ` heading or EOF +3. Replace the range in-place, stripping trailing blank lines from the old section +4. Append a blank line separator before any content that follows (only if there is content after the section) +5. If no `## Exist` section exists, append the new section to the end of the note (with a blank line separator if the note is non-empty) +6. All other note content is preserved exactly +7. The rendered section always ends with exactly one `\n`; no additional trailing newline is added when the section is at the end of the file + +**Frontmatter update strategy:** +- Parse the YAML frontmatter block into a key-value map +- Update only `mood` (integer, from `attrs["mood"].value`) and `exist_tags` (list of tag strings) +- All other existing frontmatter keys are preserved untouched +- Re-serialize the full frontmatter block; this may reorder keys relative to hand-edited frontmatter (acceptable trade-off) +- `exist_tags` is always written, even if empty (written as `exist_tags: []`) +- `mood` is only written if the `mood` attribute is present in the data + +**New note creation:** When the daily note does not exist: +1. Create all parent directories recursively (on mobile: call `vault.adapter.mkdir` for each intermediate path; `vault.create` does not create parent directories automatically) +2. Create the file with default frontmatter: `created: ` and `up: "[[Calendar]]"` +3. Then apply the normal section replacement logic (appends `## Exist` to empty content) + +**Idempotency:** Re-syncing the same date produces identical output. + +--- + +### `daily-notes.ts` — Path Resolution + +Resolves the vault-relative file path for a given date by reading configuration from whichever daily notes plugin is active. + +**Plugin precedence:** If both are active, **Periodic Notes** takes precedence over Daily Notes. + +**Periodic Notes plugin** (`periodic-notes`, community plugin): +- Settings accessed via: `(app.plugins.plugins['periodic-notes'] as any)?.settings?.daily` +- Relevant fields: `folder` (string, vault-relative folder), `format` (moment.js date format string, e.g. `YYYY-MM-DD`) + +**Daily Notes plugin** (`daily-notes`, Obsidian core plugin): +- Settings accessed via: `(app.internalPlugins.getPluginById('daily-notes') as any)?.instance?.options` +- Relevant fields: `folder` (string), `format` (moment.js date format string) + +**Path construction:** `/.md` — use moment.js (bundled with Obsidian) to format the date. + +**Fallback:** If neither plugin is installed/enabled, show a Notice: "Obsidian Exist: Daily Notes or Periodic Notes plugin required. Please enable one and configure it." and abort the sync. + +--- + +### `settings.ts` — Settings + +**User-facing settings:** +- `existToken` (string) — Exist.io personal API token; password-masked input field; link to `exist.io/account/api/` +- `syncOnStartup` (boolean, default: `true`) — whether to sync yesterday on Obsidian open + +**Internally stored (not user-facing, stored in `data.json`):** +- `lastSyncedDate` (string, ISO date, default: `""`) — the most recently successfully synced date; drives both the status bar display and startup sync logic. For backfill, this is updated after each day completes (set to that day's date), so it reflects the most recently written note even if backfill is interrupted mid-run. + +--- + +### `main.ts` — Plugin Entry Point + +**On load:** +1. Load settings from `data.json` +2. Register ribbon icon (calendar icon) +3. Register status bar item (desktop only; skip on mobile via `Platform.isMobile`) +4. Register commands +5. Update status bar from `lastSyncedDate` +6. If `syncOnStartup` is enabled and token is set: sync yesterday silently + +**Commands:** +- `"Sync Exist.io data"` — syncs yesterday; available in command palette; assignable to hotkey +- `"Backfill Exist.io data"` — opens a modal with a number input (``) labelled "How many days back?", plus OK and Cancel buttons. On OK: non-numeric or out-of-range values are clamped to 1–31; blank/empty input aborts with no action. On Cancel: close modal, no sync. Syncs the resulting range with progress notices. + +**Backfill date range:** `date_max` is yesterday; days are processed newest-first (yesterday, then day before, etc.) so the most recent data appears quickly. + +**Ribbon icon:** Triggers "Sync Exist.io data" (same as command). + +**Status bar item (desktop only):** +- `"Exist: YYYY-MM-DD"` (the actual date from `lastSyncedDate`) after a successful sync +- `"Exist: never"` if `lastSyncedDate` is empty +- `"Exist: error"` if the last sync failed +- Clicking it triggers "Sync Exist.io data" + +**Notice behavior:** +- Startup sync: silent on success; Notice on error only +- Manual sync: Notice on success (`"Exist.io synced"`) and on error +- Backfill: progress Notice (`"Exist.io: syncing 3/7…"`) updated as each day completes; final success/error Notice at end + +--- + +## Error Handling + +- Missing token: Notice prompting user to open plugin settings +- Daily Notes / Periodic Notes plugin not found/configured: Notice explaining the dependency; abort sync +- API 401: Notice "Exist.io: invalid token. Check plugin settings." +- Network error: Notice "Exist.io: network error. Check your connection." +- Date with no Exist data: Skip silently (log to console), continue to next date +- All errors set status bar to `"Exist: error"` (desktop only) + +--- + +## Mobile Compatibility + +- All HTTP: `requestUrl` only — never `fetch`, `XMLHttpRequest`, or Node built-ins +- File creation: `vault.adapter.mkdir` called recursively for parent directories before `vault.create` +- Status bar: registered only on desktop (`Platform.isMobile` guard) +- No `path`, `fs`, or other Node.js module imports +- `moment` (date formatting): imported from Obsidian's bundled version, not npm + +--- + +## Dependencies + +- **Obsidian API** — all platform APIs (vault, settings, UI, moment) +- **Daily Notes / Periodic Notes plugin** — runtime dependency for note path resolution; not bundled +- No external npm runtime dependencies + +**Dev dependencies:** esbuild (bundler), TypeScript, `@types/node`, `obsidian` type definitions diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..b149e80 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,39 @@ +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(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..1d0af1c --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "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 +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f536197 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,583 @@ +{ + "name": "obsidian-exist", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-exist", + "version": "1.0.0", + "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" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.3.tgz", + "integrity": "sha512-1Mlz934GvbgdDmt26rTLmf03cAgLg5HyOgJN+ZGCeP3Q9ynYTNMn2/LQxIl7Uy+o4K6Rfi2OuLsr12JQQR8gNg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.3.tgz", + "integrity": "sha512-XvJsYo3dO3Pi4kpalkyMvfQsjxPWHYjoX4MDiB/FUM4YMfWcXa5l4VCwFWVYI1+92yxqjuqrhNg0CZg3gSouyQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.3.tgz", + "integrity": "sha512-nuV2CmLS07Gqh5/GrZLuqkU9Bm6H6vcCspM+zjp9TdQlxJtIe+qqEXQChmfc7nWdyr/yz3h45Utk1tUn8Cz5+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.3.tgz", + "integrity": "sha512-01Hxaaat6m0Xp9AXGM8mjFtqqwDjzlMP0eQq9zll9U85ttVALGCGDuEvra5Feu/NbP5AEP1MaopPwzsTcUq1cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.3.tgz", + "integrity": "sha512-Eo2gq0Q/er2muf8Z83X21UFoB7EU6/m3GNKvrhACJkjVThd0uA+8RfKpfNhuMCl1bKRfBzKOk6xaYKQZ4lZqvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.3.tgz", + "integrity": "sha512-CN62ESxaquP61n1ZjQP/jZte8CE09M6kNn3baos2SeUfdVBkWN5n6vGp2iKyb/bm/x4JQzEvJgRHLGd5F5b81w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.3.tgz", + "integrity": "sha512-feq+K8TxIznZE+zhdVurF3WNJ/Sa35dQNYbaqM/wsCbWdzXr5lyq+AaTUSER2cUR+SXPnd/EY75EPRjf4s1SLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.3.tgz", + "integrity": "sha512-CLP3EgyNuPcg2cshbwkqYy5bbAgK+VhyfMU7oIYyn+x4Y67xb5C5ylxsNUjRmr8BX+MW3YhVNm6Lq6FKtRTWHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.3.tgz", + "integrity": "sha512-JHeZXD4auLYBnrKn6JYJ0o5nWJI9PhChA/Nt0G4MvLaMrvXuWnY93R3a7PiXeJQphpL1nYsaMcoV2QtuvRnF/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.3.tgz", + "integrity": "sha512-FyXlD2ZjZqTFh0sOQxFDiWG1uQUEOLbEh9gKN/7pFxck5Vw0qjWSDqbn6C10GAa1rXJpwsntHcmLqydY9ST9ZA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.3.tgz", + "integrity": "sha512-OrDGMvDBI2g7s04J8dh8/I7eSO+/E7nMDT2Z5IruBfUO/RiigF1OF6xoH33Dn4W/OwAWSUf1s2nXamb28ZklTA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.3.tgz", + "integrity": "sha512-DcnUpXnVCJvmv0TzuLwKBC2nsQHle8EIiAJiJ+PipEVC16wHXaPEKP0EqN8WnBe0TPvMITOUlP2aiL5YMld+CQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.3.tgz", + "integrity": "sha512-BDYf/l1WVhWE+FHAW3FzZPtVlk9QsrwsxGzABmN4g8bTjmhazsId3h127pliDRRu5674k1Y2RWejbpN46N9ZhQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.3.tgz", + "integrity": "sha512-WViAxWYMRIi+prTJTyV1wnqd2mS2cPqJlN85oscVhXdb/ZTFJdrpaqm/uDsZPGKHtbg5TuRX/ymKdOSk41YZow==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.3.tgz", + "integrity": "sha512-Iw8lkNHUC4oGP1O/KhumcVy77u2s6+KUjieUqzEU3XuWJqZ+AY7uVMrrCbAiwWTkpQHkr00BuXH5RpC6Sb/7Ug==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.3.tgz", + "integrity": "sha512-0AGkWQMzeoeAtXQRNB3s4J1/T2XbigM2/Mn2yU1tQSmQRmHIZdkGbVq2A3aDdNslPyhb9/lH0S5GMTZ4xsjBqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.3.tgz", + "integrity": "sha512-4+rR/WHOxIVh53UIQIICryjdoKdHsFZFD4zLSonJ9RRw7bhKzVyXbnRPsWSfwybYqw9sB7ots/SYyufL1mBpEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.3.tgz", + "integrity": "sha512-cVpWnkx9IYg99EjGxa5Gc0XmqumtAwK3aoz7O4Dii2vko+qXbkHoujWA68cqXjhh6TsLaQelfDO4MVnyr+ODeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.3.tgz", + "integrity": "sha512-RxmhKLbTCDAY2xOfrww6ieIZkZF+KBqG7S2Ako2SljKXRFi+0863PspK74QQ7JpmWwncChY25JTJSbVBYGQk2Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.3.tgz", + "integrity": "sha512-0r36VeEJ4efwmofxVJRXDjVRP2jTmv877zc+i+Pc7MNsIr38NfsjkQj23AfF7l0WbB+RQ7VUb+LDiqC/KY/M/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.3.tgz", + "integrity": "sha512-wgO6rc7uGStH22nur4aLFcq7Wh86bE9cOFmfTr/yxN3BXvDEdCSXyKkO+U5JIt53eTOgC47v9k/C1bITWL/Teg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.3.tgz", + "integrity": "sha512-FdVl64OIuiKjgXBjwZaJLKp0eaEckifbhn10dXWhysMJkWblg3OEEGKSIyhiD5RSgAya8WzP3DNkngtIg3Nt7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "16.18.126", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.126.tgz", + "integrity": "sha512-OTcgaiwfGFBKacvfwuHzzn1KLxH/er8mluiy8/uM3sGXHaRe73RrSIj01jow9t4kJEW633Ov+cOexXeiApTyAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.17.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.3.tgz", + "integrity": "sha512-9n3AsBRe6sIyOc6kmoXg2ypCLgf3eZSraWFRpnkto+svt8cZNuKTkb1bhQcitBcvIqjNiK7K0J3KPmwGSfkA8g==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.3", + "@esbuild/android-arm64": "0.17.3", + "@esbuild/android-x64": "0.17.3", + "@esbuild/darwin-arm64": "0.17.3", + "@esbuild/darwin-x64": "0.17.3", + "@esbuild/freebsd-arm64": "0.17.3", + "@esbuild/freebsd-x64": "0.17.3", + "@esbuild/linux-arm": "0.17.3", + "@esbuild/linux-arm64": "0.17.3", + "@esbuild/linux-ia32": "0.17.3", + "@esbuild/linux-loong64": "0.17.3", + "@esbuild/linux-mips64el": "0.17.3", + "@esbuild/linux-ppc64": "0.17.3", + "@esbuild/linux-riscv64": "0.17.3", + "@esbuild/linux-s390x": "0.17.3", + "@esbuild/linux-x64": "0.17.3", + "@esbuild/netbsd-x64": "0.17.3", + "@esbuild/openbsd-x64": "0.17.3", + "@esbuild/sunos-x64": "0.17.3", + "@esbuild/win32-arm64": "0.17.3", + "@esbuild/win32-ia32": "0.17.3", + "@esbuild/win32-x64": "0.17.3" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.12.3", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz", + "integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.0.tgz", + "integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==", + "dev": true, + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.7.4.tgz", + "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..adc5b1d --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "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" + } +} diff --git a/src/api.ts b/src/api.ts new file mode 100644 index 0000000..f4259d4 --- /dev/null +++ b/src/api.ts @@ -0,0 +1,132 @@ +import { requestUrl } from "obsidian"; + +const BASE_URL = "https://exist.io/api/2"; +const VALUE_TYPE_BOOLEAN = 7; + +export interface AttrValue { + value: unknown; + valueType: number; + label: string; + group: string; + groupLabel: string; +} + +export interface ExistData { + date: string; // ISO date, e.g. "2026-03-26" + attrs: Record; // keyed by attribute name, e.g. "mood", "steps" + tags: string[]; // labels of active boolean custom attributes + insights: string[]; // insight text strings +} + +export class ExistApiError extends Error { + constructor(public status: number, public url: string) { + super(`Exist API error ${status} at ${url}`); + } +} + +/** + * Fetch all Exist.io data for a date range (max 31 days). + * dateMin and dateMax are ISO date strings (YYYY-MM-DD). + * Returns a map of ISO date string → ExistData. + */ +export async function fetchRange( + token: string, + dateMin: string, + dateMax: string +): Promise> { + const headers = { Authorization: `Bearer ${token}` }; + const results: Record = {}; + + const daysCount = daysBetween(dateMin, dateMax) + 1; + + // Fetch attributes with values + const attrUrl = new URL(`${BASE_URL}/attributes/with-values/`); + attrUrl.searchParams.set("date_max", dateMax); + attrUrl.searchParams.set("days", String(daysCount)); + attrUrl.searchParams.set("limit", "100"); + + const attrItems = await paginate(attrUrl.toString(), headers); + + for (const attrObj of attrItems as Record[]) { + const vtype = attrObj["value_type"] as number; + const groupObj = (attrObj["group"] ?? {}) as Record; + const group = groupObj["name"] ?? ""; + const groupLabel = groupObj["label"] ?? group; + const label = attrObj["label"] as string; + const name = attrObj["name"] as string; + + for (const v of (attrObj["values"] ?? []) as Array<{ value: unknown; date: string }>) { + if (v.value === null || v.value === undefined) continue; + const d = v.date; + + if (vtype === VALUE_TYPE_BOOLEAN) { + // Boolean attribute: only active (value=1) custom group attrs become tags + if (group === "custom" && v.value === 1) { + ensure(results, d); + results[d].tags.push(label); + } + // All other boolean attrs are dropped + } else { + ensure(results, d); + results[d].attrs[name] = { + value: v.value, + valueType: vtype, + label, + group, + groupLabel, + }; + } + } + } + + // Fetch insights + const insightUrl = new URL(`${BASE_URL}/insights/`); + insightUrl.searchParams.set("date_min", dateMin); + insightUrl.searchParams.set("date_max", dateMax); + insightUrl.searchParams.set("limit", "100"); + + const insightItems = await paginate(insightUrl.toString(), headers); + + for (const insight of insightItems as Record[]) { + const target = insight["target_date"] as string; + const text = insight["text"] as string; + if (!target || !text) continue; + if (target < dateMin || target > dateMax) continue; + ensure(results, target); + results[target].insights.push(text); + } + + return results; +} + +function ensure(results: Record, date: string): void { + if (!results[date]) { + results[date] = { date, attrs: {}, tags: [], insights: [] }; + } +} + +async function paginate( + firstUrl: string, + headers: Record +): Promise { + const items: unknown[] = []; + let url: string | null = firstUrl; + + while (url) { + const resp = await requestUrl({ url, headers }); + if (resp.status !== 200) { + throw new ExistApiError(resp.status, url); + } + const body = resp.json as { results?: unknown[]; next?: string | null }; + items.push(...(body.results ?? [])); + url = body.next ?? null; + } + + return items; +} + +function daysBetween(dateMin: string, dateMax: string): number { + const d1 = new Date(dateMin).getTime(); + const d2 = new Date(dateMax).getTime(); + return Math.round((d2 - d1) / (1000 * 60 * 60 * 24)); +} diff --git a/src/daily-notes.ts b/src/daily-notes.ts new file mode 100644 index 0000000..a0a078f --- /dev/null +++ b/src/daily-notes.ts @@ -0,0 +1,40 @@ +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`; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..1a7aaca --- /dev/null +++ b/src/main.ts @@ -0,0 +1,239 @@ +import { App, Modal, moment, Notice, Platform, Plugin } from "obsidian"; +import { ExistApiError, fetchRange } from "./api"; +import { getDailyNotePath } from "./daily-notes"; +import { updateNote } from "./notes"; +import { DEFAULT_SETTINGS, ExistSettings, ExistSettingTab } from "./settings"; + +export default class ExistPlugin extends Plugin { + settings: ExistSettings; + private statusBarItem: HTMLElement | null = null; + + async onload(): Promise { + await this.loadSettings(); + + // Ribbon icon — triggers manual sync + this.addRibbonIcon("calendar-sync", "Sync Exist.io data", () => { + this.syncYesterday(true); + }); + + // Status bar item (desktop only) + if (!Platform.isMobile) { + this.statusBarItem = this.addStatusBarItem(); + this.statusBarItem.style.cursor = "pointer"; + this.statusBarItem.addEventListener("click", () => this.syncYesterday(true)); + this.updateStatusBar(false); + } + + // Command: sync yesterday + this.addCommand({ + id: "sync-exist", + name: "Sync Exist.io data", + callback: () => this.syncYesterday(true), + }); + + // Command: backfill N days + this.addCommand({ + id: "backfill-exist", + name: "Backfill Exist.io data", + callback: () => new BackfillModal(this.app, this).open(), + }); + + this.addSettingTab(new ExistSettingTab(this.app, this)); + + // Startup sync (silent) + if (this.settings.syncOnStartup && this.settings.existToken) { + this.syncYesterday(false); + } + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + updateStatusBar(error: boolean): void { + if (!this.statusBarItem) return; + if (error) { + this.statusBarItem.setText("Exist: error"); + } else if (!this.settings.lastSyncedDate) { + this.statusBarItem.setText("Exist: never"); + } else { + this.statusBarItem.setText(`Exist: ${this.settings.lastSyncedDate}`); + } + } + + /** ISO date string for yesterday */ + private yesterday(): string { + return moment().subtract(1, "day").format("YYYY-MM-DD"); + } + + /** ISO date string for N days before today */ + private dateMinusN(n: number): string { + return moment().subtract(n, "days").format("YYYY-MM-DD"); + } + + /** + * Sync yesterday's data. + * @param showSuccess - if true, show a notice on success; if false (startup), stay silent. + */ + async syncYesterday(showSuccess: boolean): Promise { + if (!this.settings.existToken) { + new Notice("Exist.io: no token configured. Open plugin settings."); + return; + } + + const date = this.yesterday(); + + try { + const notePath = getDailyNotePath(this.app, date); + const data = await fetchRange(this.settings.existToken, date, date); + + if (data[date]) { + await updateNote(this.app, data[date], notePath); + this.settings.lastSyncedDate = date; + await this.saveSettings(); + this.updateStatusBar(false); + } else { + console.log(`Exist.io: no data for ${date}`); + } + + if (showSuccess) new Notice("Exist.io synced"); + } catch (e) { + this.handleError(e); + } + } + + /** + * Sync the last `days` days, processing newest-first. + * Called from BackfillModal after the user confirms. + */ + async backfillDays(days: number): Promise { + if (!this.settings.existToken) { + new Notice("Exist.io: no token configured. Open plugin settings."); + return; + } + + const clamped = Math.max(1, Math.min(31, days)); + const dateMax = this.yesterday(); // yesterday + const dateMin = this.dateMinusN(clamped); // N days before today + + // Dates in newest-first order (yesterday, day before, ..., dateMin) + const dates: string[] = []; + for (let i = 1; i <= clamped; i++) { + dates.push(this.dateMinusN(i)); + } + + const notice = new Notice(`Exist.io: syncing 0/${clamped}…`, 0); + + try { + const allData = await fetchRange(this.settings.existToken, dateMin, dateMax); + + for (let i = 0; i < dates.length; i++) { + const date = dates[i]; + notice.setMessage(`Exist.io: syncing ${i + 1}/${clamped}…`); + + try { + const notePath = getDailyNotePath(this.app, date); + if (allData[date]) { + await updateNote(this.app, allData[date], notePath); + } else { + console.log(`Exist.io: no data for ${date}`); + } + // Update lastSyncedDate only 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))); + 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(); + } +} diff --git a/src/notes.ts b/src/notes.ts new file mode 100644 index 0000000..56d8755 --- /dev/null +++ b/src/notes.ts @@ -0,0 +1,322 @@ +import { App, normalizePath, 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. +// Exist.io value type IDs +const VT_INTEGER = 0; +const VT_FLOAT = 1; +const VT_STRING = 2; +const VT_DURATION = 3; +const VT_PERCENTAGE = 5; +const VT_SCALE = 8; + +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 === VT_INTEGER || valueType === VT_SCALE) { + return String(Math.floor(Number(value))); + } + if (valueType === VT_FLOAT) { + return Number(value).toFixed(1); + } + if (valueType === VT_DURATION) { + // 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 === VT_PERCENTAGE) { + return `${Number(value).toFixed(1)}%`; + } + // string (VT_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, duration, percentage, scale: omit if value is 0 + if (valueType === VT_INTEGER || valueType === VT_DURATION || valueType === VT_PERCENTAGE || valueType === VT_SCALE) { + return value === 0; + } + // float, string, TimeOfDay, Period: 0 and "" are legitimate values, never omit + return false; +} + +// --- Group Sorting --- + +function groupSortKey(group: string): [number, string] { + const idx = GROUP_ORDER.indexOf(group); + return idx >= 0 ? [idx, group] : [GROUP_ORDER.length, group]; +} + +// --- Section Rendering --- + +/** + * Render the full ## Exist section as a string. + * The returned string always ends with exactly one \n. + */ +export function renderExistSection(data: ExistData): string { + const lines: string[] = ["## Exist"]; + + // Bucket non-zero attrs by group + const groups: Record> = {}; + for (const [name, av] of Object.entries(data.attrs)) { + if (isZeroOmittable(name, av.value, av.valueType)) continue; + if (!groups[av.group]) groups[av.group] = []; + groups[av.group].push([name, av]); + } + + // Render non-custom groups in predefined order + const nonCustomGroups = Object.keys(groups).filter((g) => g !== "custom"); + nonCustomGroups.sort((a, b) => { + const [ai, as_] = groupSortKey(a); + const [bi, bs] = groupSortKey(b); + return ai !== bi ? ai - bi : as_.localeCompare(bs); + }); + + for (const groupName of nonCustomGroups) { + const attrsInGroup = groups[groupName]; + const groupLabel = attrsInGroup[0][1].groupLabel; + lines.push("", `### ${groupLabel}`, ""); + + let moodNoteVal: string | null = null; + for (const [name, av] of attrsInGroup) { + if (name === "mood_note") { + // Deferred: rendered as blockquote at end of group + const val = String(av.value).trim(); + moodNoteVal = val || null; + continue; + } + lines.push(`${av.label}:: ${formatValue(av.value, av.valueType)}`); + } + if (moodNoteVal) { + lines.push("", `> ${moodNoteVal}`); + } + } + + // Insights subsection + if (data.insights.length > 0) { + lines.push("", "### Insights", ""); + for (const text of data.insights) { + lines.push(`> ${text}`); + } + } + + // Custom group: non-boolean custom attrs + tags line + const customAttrs = groups["custom"] ?? []; + if (customAttrs.length > 0 || data.tags.length > 0) { + lines.push("", "### Custom", ""); + for (const [, av] of customAttrs) { + lines.push(`${av.label}:: ${formatValue(av.value, av.valueType)}`); + } + if (data.tags.length > 0) { + lines.push(`Tags:: ${data.tags.join(", ")}`); + } + } + + return lines.join("\n") + "\n"; +} + +// --- Section Replacement --- + +/** + * Replace the ## Exist section in `content` with `newSection`. + * If no ## Exist section exists, appends to end. + * Preserves all other content exactly. + */ +export function replaceExistSection(content: string, newSection: string): string { + const lines = content.split("\n"); + + // Find ## Exist heading + let start = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trimEnd() === "## Exist") { + start = i; + break; + } + } + + if (start === -1) { + // Append to end + const stripped = content.trimEnd(); + if (!stripped) return newSection; + return stripped + "\n\n" + newSection; + } + + // Find end of section: next ## heading or EOF + let end = lines.length; + for (let i = start + 1; i < lines.length; i++) { + if (lines[i].startsWith("## ")) { + end = i; + break; + } + } + + // Strip trailing blank lines from the old section range + while (end > start + 1 && lines[end - 1].trim() === "") { + end--; + } + + const before = lines.slice(0, start); + let after = lines.slice(end); + + // Strip leading blank lines from after + while (after.length > 0 && after[0].trim() === "") { + after = after.slice(1); + } + + // Rebuild: before + newSection + blank separator + after (if any) + const beforeStr = before.length > 0 ? before.join("\n") + "\n" : ""; + if (after.length > 0) { + return beforeStr + newSection + "\n" + after.join("\n"); + } + return beforeStr + newSection; +} + +// --- Frontmatter helpers --- + +/** Serialize tags as a YAML flow sequence (e.g. [Tag1, "Tag: 2"]) */ +function serializeTags(tags: string[]): string { + if (tags.length === 0) return "[]"; + return "[" + tags.map(t => /[,[\]{}"':#]/.test(t) ? JSON.stringify(t) : t).join(", ") + "]"; +} + +/** + * Update or insert specific keys in raw YAML frontmatter text. + * Handles multi-line block sequences (e.g. exist_tags written as a list). + * Keys in `skipIfPresent` are left untouched when already in `fm`. + */ +function patchFrontmatter( + fm: string, + updates: Record, + skipIfPresent: string[] = [], +): string { + let result = fm; + for (const [key, value] of Object.entries(updates)) { + const present = new RegExp(`^${key}\\s*:`, "m").test(result); + if (present && skipIfPresent.includes(key)) continue; + if (present) { + // Replace key line plus any following indented continuation lines + result = result.replace( + new RegExp(`^${key}\\s*:.*(?:\\n[ \\t]+.*)*`, "m"), + `${key}: ${value}`, + ); + } else { + if (result !== "" && !result.endsWith("\n")) result += "\n"; + result += `${key}: ${value}\n`; + } + } + return result; +} + +/** + * Split note content into raw frontmatter text and body. + * Handles empty frontmatter ("---\n---\n") and notes without frontmatter. + */ +function splitContent(content: string): { fm: string; body: string } { + if (content.startsWith("---\n")) { + // Search from offset 1 so the empty-FM case "---\n---\n" is found at idx 3 + const closeIdx = content.indexOf("\n---", 1); + if (closeIdx !== -1) { + const afterDashes = closeIdx + 4; + if (afterDashes >= content.length || content[afterDashes] === "\n") { + return { + fm: content.slice(4, closeIdx), // raw YAML (may be "") + body: content.slice(afterDashes + 1), // everything after \n---\n + }; + } + } + } + return { fm: "", body: content }; +} + +// --- File I/O --- + +/** + * Create all ancestor directories of a vault path. + * vault.create() does not create parents automatically on mobile. + */ +async function ensureParentDirs(app: App, filePath: string): Promise { + const dir = filePath.substring(0, filePath.lastIndexOf("/")); + if (!dir) return; + const parts = dir.split("/").filter(Boolean); + let current = ""; + for (const part of parts) { + current = current ? `${current}/${part}` : part; + if (!app.vault.getAbstractFileByPath(current)) { + try { + await app.vault.adapter.mkdir(normalizePath(current)); + } catch { + // Already exists — safe to ignore + } + } + } +} + +/** + * Update (or create) the daily note at `filePath` with Exist data. + * Idempotent: re-running with the same data produces identical output. + * + * Performs a single vault.modify so open notes aren't written twice + * (double-writes cause blank lines to appear in the editor). + */ +export async function updateNote( + app: App, + data: ExistData, + filePath: string +): Promise { + const normalized = normalizePath(filePath); + let tfile: TFile; + + const existing = app.vault.getAbstractFileByPath(normalized); + if (existing instanceof TFile) { + tfile = existing; + } else { + await ensureParentDirs(app, normalized); + tfile = await app.vault.create( + normalized, + `---\ncreated: ${data.date}\nup: "[[Calendar]]"\n---\n`, + ); + } + + const content = await app.vault.read(tfile); + + // Split into raw frontmatter text + body + let { fm, body } = splitContent(content); + + // Patch frontmatter keys (single string replacement, no full YAML round-trip) + const moodAttr = data.attrs["mood"]; + fm = patchFrontmatter( + fm, + { + exist_tags: serializeTags(data.tags), + ...(moodAttr !== undefined + ? { mood: String(Math.round(Number(moodAttr.value))) } + : {}), + created: data.date, + up: '"[[Calendar]]"', + }, + ["created", "up"], // only add these if missing + ); + if (!fm.endsWith("\n")) fm += "\n"; + + // Update Exist section in body + const section = renderExistSection(data); + const newBody = replaceExistSection(body, section); + + // Single write — avoids double-write artifacts on open notes + await app.vault.modify(tfile, `---\n${fm}---\n${newBody}`); +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..aee7fbd --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,63 @@ +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(); + }) + ); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b87d311 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": ["DOM", "ES5", "ES6", "ES7"] + }, + "include": ["src/**/*.ts"] +}