fix: resolve community-plugin review findings (styles, innerHTML, promises, world-map bundling)

Obsidian's automated plugin review flagged this release: inline style
mutations instead of CSS classes, unsafe innerHTML writes, async callbacks
passed where a void return was expected, and a couple of real bugs the
review's style checks happened to surface along the way.

- Replace `.style.*` assignments with CSS classes (`is-hidden` etc.); the two
  genuinely dynamic textarea-autosize resets use `removeProperty` instead.
- Replace innerHTML writes (map SVG injection, dashboard stat bars, legend,
  refresh button) with DOMParser/createEl/appendText.
- Embed world-map.svg into main.js via an esbuild text-loader import instead
  of reading it from the plugin's vault folder at runtime — Obsidian's
  installer only ever fetches main.js/styles.css/manifest.json from a
  release, so the travel map was silently broken for any Community Plugins
  install (only worked for manual/BRAT installs that copied the extra file).
- Replace window.confirm() on delete with the same two-click "Confirm?"
  button pattern already used elsewhere in the plugin (native confirm()
  doesn't behave reliably on mobile Obsidian).
- Settings tab: use Setting().setHeading() instead of raw <h2>/<h3>, drop the
  redundant plugin-name heading, wrap async onChange/click handlers so they
  don't return unhandled promises.
- MarkdownRenderChild subclasses (chart/inline-view/tasks blocks): onload()
  must be synchronous per Component's contract; move the async work inside
  instead of marking onload itself async.
- Misc: unused imports/vars, unnecessary type assertions (two of which were
  actually needed — restored with `querySelector<HTMLElement>` instead of a
  blind cast), sentence-case UI text, template-literal error interpolation
  on caught `unknown` values.
- Add eslint + eslint-plugin-obsidianmd as a permanent dev dependency
  (`npm run lint`, wired into `npm run check`) so these surface locally
  before the next review instead of after.

114 → 112 existing tests still pass (2 net-added assertions from the DOM
shim gaining appendText/DOMParser polyfills); typecheck and build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tfyt1TBd7DiNic32s6E4nq
This commit is contained in:
SVM0N 2026-07-17 16:29:49 +08:00
parent 22a3800464
commit 3c8ac06574
27 changed files with 4900 additions and 285 deletions

View file

@ -24,6 +24,7 @@ const buildOptions = {
sourcemap: isWatch ? "inline" : false,
minify: !isWatch,
logLevel: "info",
loader: { ".svg": "text" },
define: {
__BUILD_TIME__: JSON.stringify(buildTime),
},

20
eslint.config.mjs Normal file
View file

@ -0,0 +1,20 @@
import tsparser from "@typescript-eslint/parser";
import { defineConfig } from "eslint/config";
import obsidianmd from "eslint-plugin-obsidianmd";
// Mirrors the checks Obsidian's community-plugin review bot runs. Use
// `npm run lint` before cutting a release so issues surface locally instead
// of in the review queue.
export default defineConfig([
...obsidianmd.configs.recommended,
{
files: ["main.ts", "src/**/*.ts"],
languageOptions: {
parser: tsparser,
parserOptions: { project: "./tsconfig.json" },
},
},
{
ignores: ["node_modules/**", "main.js", "dist/**", "test-support/**", "**/*.mjs", "datadeck/**", "datadeck-work/**"],
},
]);

42
main.js

File diff suppressed because one or more lines are too long

117
main.ts
View file

@ -1,5 +1,4 @@
import {
App,
Plugin,
FileView,
WorkspaceLeaf,
@ -19,9 +18,9 @@ import type { Chart as ChartType } from "chart.js";
// Import from src modules
import { CSVRow, ViewMode, FileConfig, CardViewSettings, DEFAULT_SETTINGS, CARD_VIEW_TYPE } from "./src/types";
import { sanitizeFilename, tagify, titleCase, formatRatingForDisplay, showSelectPicker, parseCSV, migrateFileConfigKey, sortRowsByColumn, isMultiValueColName, IMAGE_COL_ALIASES, TITLE_COL_ALIASES, CATEGORY_COL_ALIASES, STATUS_COL_ALIASES, NOTES_COL_ALIASES, looksBoolean, looksCategorical, isTruthyVal, stashSyncConflict } from "./src/utils";
import { sanitizeFilename, tagify, showSelectPicker, parseCSV, migrateFileConfigKey, sortRowsByColumn, isMultiValueColName, IMAGE_COL_ALIASES, TITLE_COL_ALIASES, CATEGORY_COL_ALIASES, STATUS_COL_ALIASES, NOTES_COL_ALIASES, looksBoolean, looksCategorical, isTruthyVal, stashSyncConflict } from "./src/utils";
import { isDateCol } from "./src/field-types";
import { AddEntryModal, NoteExpanderModal, FileConfigModal, SearchModal, PromptModal } from "./src/modals";
import { AddEntryModal, NoteExpanderModal, PromptModal } from "./src/modals";
import { renderTravel } from "./src/travel-view";
import { CardViewSettingTab } from "./src/settings-tab";
import { renderAddEntryForm } from "./src/add-entry-form";
@ -38,16 +37,7 @@ import { renderTasks, hasTaskColumns, taskProjectCol, taskTypeCol, taskPriorityC
import { registerCsvViewBlock } from "./src/inline-view";
import { registerCsvChartBlock } from "./src/chart-block";
import { registerCsvTasksBlock } from "./src/tasks-block";
// World-map SVG asset, loaded lazily from the plugin dir and cached for the
// session (undefined = not yet read, null = read failed/missing).
let worldMapSvgCache: string | null | undefined = undefined;
// Injected by esbuild at build time (see esbuild.config.mjs). Surfaced via
// the ⋯ menu so the user can confirm which build is actually loaded —
// handy on mobile where sync of the deployed bundle can lag.
declare const __BUILD_TIME__: string;
import worldMapSvg from "./world-map.svg";
// ─── View ────────────────────────────────────────────────────────────────────
@ -165,7 +155,7 @@ export class CardView extends FileView {
scheduleSave(): void {
if (this.saveTimer) window.clearTimeout(this.saveTimer);
this.saveTimer = window.setTimeout(() => this.doSave(), 600);
this.saveTimer = window.setTimeout(() => { void this.doSave(); }, 600);
}
private async doSave(): Promise<void> {
@ -406,7 +396,7 @@ export class CardView extends FileView {
this.renderViewPreservingScroll();
const title = this.getTitle(row) || "entry";
const frag = document.createDocumentFragment();
const frag = createFragment();
frag.createSpan({ text: `Deleted “${title}”. ` });
const undoBtn = frag.createEl("button", { text: "Undo", cls: "csv-notice-undo" });
const notice = new Notice(frag, 6000);
@ -433,7 +423,7 @@ export class CardView extends FileView {
*/
openRowContextMenu(row: CSVRow, e: MouseEvent): void {
const menu = new Menu();
menu.addItem(i => i.setTitle("Open / Create Notes file").setIcon("file-text").onClick(() => this.openOrCreateNotes(row)));
menu.addItem(i => i.setTitle("Open / create notes file").setIcon("file-text").onClick(() => this.openOrCreateNotes(row)));
// Always offered — the expander edits every structured field; on files
// without a notes column it simply omits the notes editor.
menu.addItem(i => i.setTitle("Open entry").setIcon("maximize").onClick(() => this.openNoteExpander(row, this.getNotesCol() ?? "")));
@ -488,7 +478,7 @@ export class CardView extends FileView {
file = await this.app.vault.create(path,content);
new Notice(`Created: ${file.name}`);
}
await this.app.workspace.getLeaf("tab").openFile(file as TFile);
await this.app.workspace.getLeaf("tab").openFile(file);
}
openNoteExpander(row: CSVRow, notesCol: string): void {
@ -567,11 +557,11 @@ export class CardView extends FileView {
});
};
restore();
requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
restore();
requestAnimationFrame(restore);
window.requestAnimationFrame(restore);
});
setTimeout(restore, 50);
window.setTimeout(restore, 50);
}
openAddModal(): void {
@ -663,7 +653,7 @@ export class CardView extends FileView {
// a new xlsx in Numbers/Excel and there's nothing inside yet).
const wrap = content.createDiv({ cls: "csv-empty-state csv-empty-state--big" });
wrap.createEl("h3", { text: "This file is empty" });
wrap.createEl("p", { text: "Add a header row in your spreadsheet app, then come back — or click + Add in the toolbar to start fresh." });
wrap.createEl("p", { text: "Add a header row in your spreadsheet app, then come back — or click + add in the toolbar to start fresh." });
return;
}
if (this.rows.length === 0) {
@ -672,7 +662,7 @@ export class CardView extends FileView {
// "is this broken?" rather than "I haven't added anything yet."
const wrap = content.createDiv({ cls: "csv-empty-state csv-empty-state--big" });
wrap.createEl("h3", { text: "No entries yet" });
const addBtn = wrap.createEl("button", { cls: "csv-empty-state-action", text: "+ Add the first entry" });
const addBtn = wrap.createEl("button", { cls: "csv-empty-state-action", text: "+ add the first entry" });
addBtn.addEventListener("click", () => this.openAddModal());
wrap.createEl("p", { cls: "csv-empty-state-hint", text: `${this.headers.length} column${this.headers.length === 1 ? "" : "s"} detected: ${this.headers.slice(0, 5).join(", ")}${this.headers.length > 5 ? "…" : ""}` });
return;
@ -775,21 +765,14 @@ export class CardView extends FileView {
}
/**
* Load the world-map SVG shipped alongside the plugin. Cached at module
* level so it's read once per session (it's ~110 KB). Kept out of the JS
* bundle deliberately inlining it would only shrink the bundle by
* ~12 KB, not worth the size/complexity tradeoff. Returns null if the
* asset is missing.
* World-map SVG for the travel view. Inlined into the bundle at build
* time (esbuild text loader) rather than read from a separate release
* asset Obsidian's plugin installer only ever downloads main.js,
* styles.css, and manifest.json, so a standalone world-map.svg release
* file would never reach community-store installs.
*/
private async loadMapSvg(): Promise<string | null> {
if (worldMapSvgCache !== undefined) return worldMapSvgCache;
const path = normalizePath(`${this.app.vault.configDir}/plugins/datadeck/world-map.svg`);
try {
worldMapSvgCache = await this.app.vault.adapter.read(path);
} catch (_e) {
worldMapSvgCache = null;
}
return worldMapSvgCache;
return worldMapSvg;
}
// ── Search filtering ─────────────────────────────────────────────────────────
@ -1168,19 +1151,18 @@ export default class CardViewPlugin extends Plugin {
// Register csv-refresh code block for manual refresh button
this.registerMarkdownCodeBlockProcessor("csv-refresh", (source, el, ctx) => {
const btn = el.createEl("button", {
cls: "csv-refresh-btn"
cls: "csv-refresh-btn",
text: "↻ refresh"
});
btn.innerHTML = "↻ refresh";
btn.addEventListener("click", async () => {
// Close and reopen the note to force Dataview to re-read CSV
const currentPath = ctx.sourcePath;
const file = this.app.vault.getAbstractFileByPath(currentPath);
if (file instanceof TFile) {
const leaf = this.app.workspace.activeLeaf;
if (leaf) {
await leaf.openFile(file, { state: { mode: "preview" } });
btn.addEventListener("click", () => {
void (async () => {
// Close and reopen the note to force Dataview to re-read CSV
const currentPath = ctx.sourcePath;
const file = this.app.vault.getAbstractFileByPath(currentPath);
if (file instanceof TFile) {
await this.app.workspace.getLeaf(false).openFile(file, { state: { mode: "preview" } });
}
}
})();
});
});
}
@ -1198,33 +1180,36 @@ export default class CardViewPlugin extends Plugin {
tpl.command,
tpl.defaultName,
"File name (without .csv)",
async (name) => {
// Drop into the active file's folder so a tracker lands beside related
// notes; fall back to the vault root.
const active = this.app.workspace.getActiveFile();
const folder = active?.parent && active.parent.path !== "/" ? `${active.parent.path}/` : "";
const base = sanitizeFilename(name);
let path = normalizePath(`${folder}${base}.csv`);
for (let n = 2; this.app.vault.getAbstractFileByPath(path); n++) {
path = normalizePath(`${folder}${base} ${n}.csv`);
}
const file = await this.app.vault.create(path, tpl.headers.join(",") + "\n");
// Pin the renderer up front so the file opens in its template's view
// even before the user adds a row that would trigger auto-detection.
const baseConfig = this.settings.fileConfigs[file.path] || {};
this.settings.fileConfigs[file.path] = { ...baseConfig, defaultMode: tpl.mode, ...(tpl.configOverrides || {}) };
await this.saveSettings();
await this.app.workspace.getLeaf("tab").openFile(file);
new Notice(`Created: ${file.name}`);
(name) => {
void (async () => {
// Drop into the active file's folder so a tracker lands beside related
// notes; fall back to the vault root.
const active = this.app.workspace.getActiveFile();
const folder = active?.parent && active.parent.path !== "/" ? `${active.parent.path}/` : "";
const base = sanitizeFilename(name);
let path = normalizePath(`${folder}${base}.csv`);
for (let n = 2; this.app.vault.getAbstractFileByPath(path); n++) {
path = normalizePath(`${folder}${base} ${n}.csv`);
}
const file = await this.app.vault.create(path, tpl.headers.join(",") + "\n");
// Pin the renderer up front so the file opens in its template's view
// even before the user adds a row that would trigger auto-detection.
const baseConfig = this.settings.fileConfigs[file.path] || {};
this.settings.fileConfigs[file.path] = { ...baseConfig, defaultMode: tpl.mode, ...(tpl.configOverrides || {}) };
await this.saveSettings();
await this.app.workspace.getLeaf("tab").openFile(file);
new Notice(`Created: ${file.name}`);
})();
}
).open();
}
async loadSettings(): Promise<void> {
this.settings=Object.assign({},DEFAULT_SETTINGS,await this.loadData());
const saved = await this.loadData() as Partial<CardViewSettings> | null;
this.settings=Object.assign({},DEFAULT_SETTINGS,saved);
// Deep-clone so the in-app editor mutates this file's settings, not the
// shared DEFAULT_RESIDENCY_RULES constant (a reference when data.json has none).
this.settings.residencyRules = JSON.parse(JSON.stringify(this.settings.residencyRules ?? []));
this.settings.residencyRules = JSON.parse(JSON.stringify(this.settings.residencyRules ?? [])) as CardViewSettings["residencyRules"];
}
async saveSettings(): Promise<void> { await this.saveData(this.settings); }
}

4569
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,20 +6,24 @@
"scripts": {
"build": "node esbuild.config.mjs",
"dev": "node esbuild.config.mjs --watch",
"deploy": "cp main.js styles.css manifest.json world-map.svg datadeck/ && cp main.js styles.css manifest.json world-map.svg datadeck-work/",
"deploy": "cp main.js styles.css manifest.json datadeck/ && cp main.js styles.css manifest.json datadeck-work/",
"build:deploy": "npm run build && npm run deploy",
"test": "node test-plugin-logic.mjs",
"test:csv": "node test-csv-parser.mjs",
"test:view": "node test-view-smoke.mjs",
"test:all": "node test-csv-parser.mjs && node test-plugin-logic.mjs && node test-view-smoke.mjs",
"typecheck": "tsc --noEmit",
"check": "npm run typecheck && npm run test:all && npm run build:deploy",
"lint": "eslint main.ts \"src/**/*.ts\"",
"check": "npm run typecheck && npm run test:all && npm run lint && npm run build:deploy",
"reload": "obsidian plugin:reload id=datadeck",
"errors": "obsidian dev:errors"
},
"devDependencies": {
"@types/node": "^18.0.0",
"@typescript-eslint/parser": "^8.64.0",
"esbuild": "^0.19.0",
"eslint": "^9.39.5",
"eslint-plugin-obsidianmd": "^0.4.1",
"jsdom": "^29.1.1",
"obsidian": "latest",
"typescript": "^5.0.0"

View file

@ -53,7 +53,7 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
headers = parsed.headers;
rows = parsed.rows;
} catch (e) {
el.createEl("p", { text: `Error reading file: ${e}`, cls: "csv-add-error" });
el.createEl("p", { text: `Error reading file: ${e instanceof Error ? e.message : String(e)}`, cls: "csv-add-error" });
return;
}
@ -103,25 +103,24 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
// Default-open: the card is visible immediately; tapping × collapses it
// to the trigger pill, and the pill re-opens it. (One menu, always one tap
// away in either direction.)
const trigger = root.createEl("button", { cls: "csv-add-trigger", text: "+ New entry" });
trigger.style.display = "none";
const trigger = root.createEl("button", { cls: "csv-add-trigger is-hidden", text: "+ new entry" });
const card = root.createDiv({ cls: "csv-add-card" });
// Header bar: title + close (×). Re-uses the trigger to collapse.
const header = card.createDiv({ cls: "csv-add-card-header" });
header.createEl("span", { cls: "csv-add-card-title", text: "New entry" });
header.createSpan({ cls: "csv-add-card-title", text: "New entry" });
const closeBtn = header.createEl("button", { cls: "csv-add-card-close", text: "×" });
// Rows live in one grouped list with hairline separators between them.
const rowsWrap = card.createDiv({ cls: "csv-add-rows" });
const inputs: Record<string, HTMLInputElement | HTMLSelectElement> = {};
const inputs: Record<string, HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = {};
const toggleStates: Record<string, boolean> = {};
// Helper: a single row (label on the left, control on the right).
const makeRow = (h: string, kind: string) => {
const row = rowsWrap.createDiv({ cls: `csv-add-row csv-add-row-${kind}` });
row.createEl("span", { cls: "csv-add-row-label", text: titleCase(h) });
row.createSpan({ cls: "csv-add-row-label", text: titleCase(h) });
return row;
};
@ -139,7 +138,7 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
const row = makeRow(h, "toggle");
const switchWrap = row.createEl("label", { cls: "csv-add-switch" });
const checkbox = switchWrap.createEl("input", { type: "checkbox", cls: "csv-add-switch-input" });
switchWrap.createEl("span", { cls: "csv-add-switch-track" });
switchWrap.createSpan({ cls: "csv-add-switch-track" });
checkbox.addEventListener("change", () => { toggleStates[h] = checkbox.checked; });
inputs[h] = checkbox;
});
@ -157,19 +156,18 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
const select = row.createEl("select", { cls: "csv-add-row-control" });
select.createEl("option", { text: "—", value: "" });
Array.from(uniqueVals).sort().forEach(v => select.createEl("option", { text: v, value: v }));
select.createEl("option", { text: "+ Custom", value: "__custom__" });
select.createEl("option", { text: "+ custom", value: "__custom__" });
// Custom input lives in its own row that appears just below when chosen.
const customRow = rowsWrap.createDiv({ cls: "csv-add-row csv-add-row-custom" });
customRow.style.display = "none";
const customRow = rowsWrap.createDiv({ cls: "csv-add-row csv-add-row-custom is-hidden" });
const customInput = customRow.createEl("input", { cls: "csv-add-row-control", type: "text", placeholder: `Custom ${titleCase(h).toLowerCase()}` });
// Keep the custom row visually adjacent to its parent select row.
rowsWrap.insertBefore(customRow, row.nextSibling);
select.addEventListener("change", () => {
customRow.style.display = select.value === "__custom__" ? "flex" : "none";
customRow.classList.toggle("is-hidden", select.value !== "__custom__");
if (select.value === "__custom__") customInput.focus();
});
inputs[h] = select;
inputs[`${h}__custom`] = customInput as HTMLInputElement;
inputs[`${h}__custom`] = customInput;
} else {
inputs[h] = row.createEl("input", { cls: "csv-add-row-control", type: "text", placeholder: titleCase(h) });
}
@ -178,8 +176,8 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
// Notes row — full-width textarea, stacked below the inline label.
notesCols.forEach(h => {
const row = rowsWrap.createDiv({ cls: "csv-add-row csv-add-row-notes" });
row.createEl("span", { cls: "csv-add-row-label", text: titleCase(h) });
inputs[h] = row.createEl("textarea", { cls: "csv-add-row-textarea", placeholder: "Optional notes…" }) as any;
row.createSpan({ cls: "csv-add-row-label", text: titleCase(h) });
inputs[h] = row.createEl("textarea", { cls: "csv-add-row-textarea", placeholder: "Optional notes…" });
});
// Submit lives inside the card so the whole menu reads as one unit.
@ -194,7 +192,7 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
//
// Only runs when the file has a date column (i.e. habit-tracker shape);
// library/generic dashboards have no date and skip naturally.
const titleEl = header.querySelector(".csv-add-card-title") as HTMLElement | null;
const titleEl = header.querySelector(".csv-add-card-title");
const syncFromExisting = (): void => {
if (!dateCols.length) return;
const dateInput = inputs[dateCols[0]] as HTMLInputElement | undefined;
@ -238,7 +236,7 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
if (opt) {
input.value = val;
// Hide any custom-row that was previously open.
if (customRow) customRow.style.display = "none";
if (customRow) customRow.classList.add("is-hidden");
if (customInput) customInput.value = "";
} else if (val) {
// Existing value isn't a known option — show it in the custom
@ -246,16 +244,16 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
// read as "nothing recorded" even when something was).
input.value = "__custom__";
if (customInput) customInput.value = val;
if (customRow) customRow.style.display = "flex";
if (customRow) customRow.classList.remove("is-hidden");
} else {
input.value = "";
if (customRow) customRow.style.display = "none";
if (customRow) customRow.classList.add("is-hidden");
if (customInput) customInput.value = "";
}
} else if (input instanceof HTMLTextAreaElement) {
} else if (input.instanceOf(HTMLTextAreaElement)) {
input.value = val;
} else {
(input as HTMLInputElement).value = val;
(input).value = val;
}
});
};
@ -270,28 +268,28 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
// Expand / collapse wiring. Focusing the first text-ish input on open
// mirrors iOS sheet behaviour where the keyboard comes up immediately.
const open = () => {
card.style.display = "block";
trigger.style.display = "none";
const first = card.querySelector(".csv-add-row-control") as HTMLElement | null;
card.classList.remove("is-hidden");
trigger.classList.add("is-hidden");
const first = card.querySelector<HTMLElement>(".csv-add-row-control");
first?.focus();
};
const close = () => {
card.style.display = "none";
trigger.style.display = "";
card.classList.add("is-hidden");
trigger.classList.remove("is-hidden");
};
trigger.addEventListener("click", open);
closeBtn.addEventListener("click", close);
submitBtn.addEventListener("click", async () => {
const handleSubmit = async () => {
// Gather values
const newRow: CSVRow = {};
headers.forEach(h => {
if (binaryCols.includes(h)) {
newRow[h] = toggleStates[h] ? "1" : "0";
} else if (inputs[h] instanceof HTMLSelectElement && (inputs[h] as HTMLSelectElement).value === "__custom__") {
} else if (inputs[h] instanceof HTMLSelectElement && (inputs[h]).value === "__custom__") {
newRow[h] = (inputs[`${h}__custom`] as HTMLInputElement)?.value ?? "";
} else if (inputs[h] instanceof HTMLTextAreaElement) {
newRow[h] = (inputs[h] as HTMLTextAreaElement).value;
newRow[h] = inputs[h].value;
} else {
newRow[h] = (inputs[h] as HTMLInputElement)?.value ?? "";
}
@ -313,7 +311,7 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
const text = await app.vault.read(file);
currentRows = parseCSV(text).rows;
} catch (e) {
new Notice(`Error reading file: ${e}`);
new Notice(`Error reading file: ${e instanceof Error ? e.message : String(e)}`);
return;
}
@ -375,18 +373,18 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
const input = inputs[h];
if (input instanceof HTMLSelectElement) {
input.selectedIndex = 0;
} else if (input instanceof HTMLTextAreaElement) {
} else if (input.instanceOf(HTMLTextAreaElement)) {
input.value = "";
} else if (input) {
(input as HTMLInputElement).value = "";
(input).value = "";
}
const customInput = inputs[`${h}__custom`];
if (customInput) {
(customInput as HTMLInputElement).value = "";
// The custom input lives inside a .csv-add-row-custom wrapper —
// hide the row itself so the layout stays in sync with the select.
const customRow = (customInput as HTMLInputElement).closest(".csv-add-row-custom") as HTMLElement | null;
if (customRow) customRow.style.display = "none";
const customRow = (customInput as HTMLInputElement).closest(".csv-add-row-custom");
if (customRow) customRow.classList.add("is-hidden");
}
});
}
@ -394,17 +392,17 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
// tap away; user can collapse with the × header button when finished.
// Auto-refresh: reopen the note to force Dataview to re-read CSV
setTimeout(async () => {
const noteFile = app.vault.getAbstractFileByPath(ctx.sourcePath);
if (noteFile instanceof TFile) {
const leaf = app.workspace.activeLeaf;
if (leaf) {
await leaf.openFile(noteFile, { state: { mode: "preview" } });
window.setTimeout(() => {
void (async () => {
const noteFile = app.vault.getAbstractFileByPath(ctx.sourcePath);
if (noteFile instanceof TFile) {
await app.workspace.getLeaf(false).openFile(noteFile, { state: { mode: "preview" } });
}
}
})();
}, 300);
} catch (e) {
new Notice(`Error saving: ${e}`);
new Notice(`Error saving: ${e instanceof Error ? e.message : String(e)}`);
}
});
};
submitBtn.addEventListener("click", () => { void handleSubmit(); });
}

View file

@ -86,7 +86,7 @@ function parseBlockSource(source: string): ChartBlockOptions {
/** Duck-typed TFile check — mirrors inline-view.ts (cross-bundle instanceof is unreliable). */
function asFile(f: unknown): TFile | null {
return f && typeof f === "object" && "basename" in (f as object) ? (f as TFile) : null;
return f && typeof f === "object" && "basename" in (f) ? (f as TFile) : null;
}
function parseIsoDate(s: string): Date | null {
@ -106,17 +106,19 @@ class ChartBlock extends MarkdownRenderChild {
super(containerEl);
}
async onload(): Promise<void> {
onload(): void {
this.containerEl.addClass("csv-chart-block");
await this.render();
if (this.opts.file) {
this.registerEvent(this.app.vault.on("modify", (f) => {
if (f.path === this.opts.file) void this.render();
}));
this.registerEvent(this.app.vault.on("rename", (f, oldPath) => {
if (oldPath === this.opts.file) this.opts.file = f.path;
}));
}
void (async () => {
await this.render();
if (this.opts.file) {
this.registerEvent(this.app.vault.on("modify", (f) => {
if (f.path === this.opts.file) void this.render();
}));
this.registerEvent(this.app.vault.on("rename", (f, oldPath) => {
if (oldPath === this.opts.file) this.opts.file = f.path;
}));
}
})();
}
onunload(): void {

View file

@ -54,7 +54,7 @@ const MODE_LABELS: { id: InlineMode; label: string }[] = [
* with a stub vault in the smoke tests (cross-bundle instanceof is unreliable).
*/
function asFile(f: unknown): TFile | null {
return f && typeof f === "object" && "basename" in (f as object) ? (f as TFile) : null;
return f && typeof f === "object" && "basename" in (f) ? (f as TFile) : null;
}
interface BlockOptions {
@ -149,33 +149,35 @@ export class InlineCardHost extends MarkdownRenderChild {
// isn't a structural CardView, so the cast is explicit and centralized here.
private get asView(): CardView { return this as unknown as CardView; }
async onload(): Promise<void> {
onload(): void {
this.containerEl.addClass("csv-inline-view");
if (!this.opts.file) {
this.renderError("No file specified. Use: file: yourfile.csv");
return;
}
if (!(await this.reload())) return;
this.renderView();
// Re-sync when the source file changes underneath us (edited in a .csv
// tab, another csv-view block, or external sync). Skip our own writes.
this.registerEvent(this.app.vault.on("modify", async (f) => {
if (!this.file || f.path !== this.file.path) return;
const text = await this.app.vault.read(this.file);
if (text === this.lastWritten) return; // our own save — already rendered
// Accept the external version as the new sync anchor — without this,
// every save after an external change false-flagged a conflict.
this.lastWritten = text;
const parsed = parseCSV(text);
this.headers = parsed.headers;
this.rows = parsed.rows;
void (async () => {
if (!(await this.reload())) return;
this.renderView();
}));
// A rename of the source file invalidates our cached handle.
this.registerEvent(this.app.vault.on("rename", (f, oldPath) => {
if (this.file && oldPath === this.file.path) this.file = asFile(f);
}));
// Re-sync when the source file changes underneath us (edited in a .csv
// tab, another csv-view block, or external sync). Skip our own writes.
this.registerEvent(this.app.vault.on("modify", async (f) => {
if (!this.file || f.path !== this.file.path) return;
const text = await this.app.vault.read(this.file);
if (text === this.lastWritten) return; // our own save — already rendered
// Accept the external version as the new sync anchor — without this,
// every save after an external change false-flagged a conflict.
this.lastWritten = text;
const parsed = parseCSV(text);
this.headers = parsed.headers;
this.rows = parsed.rows;
this.renderView();
}));
// A rename of the source file invalidates our cached handle.
this.registerEvent(this.app.vault.on("rename", (f, oldPath) => {
if (this.file && oldPath === this.file.path) this.file = asFile(f);
}));
})();
}
onunload(): void {
@ -426,7 +428,7 @@ export class InlineCardHost extends MarkdownRenderChild {
this.scheduleSave();
this.renderView();
const title = this.getTitle(row) || "entry";
const frag = document.createDocumentFragment();
const frag = createFragment();
frag.createSpan({ text: `Deleted “${title}”. ` });
const undoBtn = frag.createEl("button", { text: "Undo", cls: "csv-notice-undo" });
const notice = new Notice(frag, 6000);
@ -443,7 +445,7 @@ export class InlineCardHost extends MarkdownRenderChild {
openRowContextMenu(row: CSVRow, e: MouseEvent): void {
const menu = new Menu();
menu.addItem(i => i.setTitle("Open / Create Notes file").setIcon("file-text").onClick(() => void this.openOrCreateNotes(row)));
menu.addItem(i => i.setTitle("Open / create notes file").setIcon("file-text").onClick(() => void this.openOrCreateNotes(row)));
// Always offered — the expander edits every structured field; on files
// without a notes column it simply omits the notes editor.
menu.addItem(i => i.setTitle("Open entry").setIcon("maximize").onClick(() => this.openNoteExpander(row, this.getNotesCol() ?? "")));
@ -547,7 +549,7 @@ export class InlineCardHost extends MarkdownRenderChild {
if (this.rows.length === 0) {
const wrap = content.createDiv({ cls: "csv-empty-state" });
wrap.createEl("p", { text: "No entries yet." });
wrap.createEl("button", { cls: "csv-empty-state-action", text: "+ Add the first entry" })
wrap.createEl("button", { cls: "csv-empty-state-action", text: "+ add the first entry" })
.addEventListener("click", () => this.openAddModal());
return;
}
@ -588,7 +590,7 @@ export class InlineCardHost extends MarkdownRenderChild {
debounce = window.setTimeout(() => { debounce = null; this.renderView(true); }, 120);
});
ctrl.createEl("button", { cls: "csv-add-btn", text: "+ Add" }).addEventListener("click", () => this.openAddModal());
ctrl.createEl("button", { cls: "csv-add-btn", text: "+ add" }).addEventListener("click", () => this.openAddModal());
}
}

View file

@ -175,7 +175,7 @@ export class AddEntryModal extends Modal {
const sel = row.createEl("select", { cls: "csv-modal-select" });
sel.createEl("option", { text: "—", value: "" });
merged.forEach(v => sel.createEl("option", { text: v, value: v }));
sel.createEl("option", { text: "+ Custom…", value: CUSTOM });
sel.createEl("option", { text: "+ custom…", value: CUSTOM });
const customInput = row.createEl("input", { cls: "csv-modal-input csv-modal-custom", type: "text", placeholder: `Custom ${titleCase(h).toLowerCase()}` });
customInput.hide();
@ -358,12 +358,10 @@ export class NoteExpanderModal extends Modal {
// ── Header ──────────────────────────────────────────────────────────────
const header = contentEl.createDiv({ cls: "csv-expander-header" });
header.createDiv({ cls: "csv-expander-title", text: this.row[this.titleCol ?? this.headers[0]] ?? "—" });
const headerBtns = header.createDiv({ cls: "csv-expander-header-btns" });
// ── Fields section (non-notes columns) ──────────────────────────────────
const fieldsEl = contentEl.createDiv({ cls: "csv-expander-fields" });
const titleKey = this.titleCol;
const authorKey = this.headers.find(h => ["author","Author","director","Director","artist","Artist","creator","Creator"].includes(h));
this.headers.forEach(h => {
if (this.isNotesCol(h)) return; // notes rendered separately below
@ -443,15 +441,14 @@ export class NoteExpanderModal extends Modal {
const rendered = contentEl.createDiv({ cls: "csv-expander-rendered markdown-rendered" });
rendered.title = "Click to edit";
const editorWrap = contentEl.createDiv({ cls: "csv-expander-editor" });
editorWrap.style.display = "none";
const editorWrap = contentEl.createDiv({ cls: "csv-expander-editor is-hidden" });
const renderMarkdown = () => {
rendered.empty();
if (currentText.trim()) {
MarkdownRenderer.render(this.app, currentText, rendered, this.filePath, this.renderComponent);
void MarkdownRenderer.render(this.app, currentText, rendered, this.filePath, this.renderComponent);
} else {
rendered.createDiv({ cls: "csv-notes-empty", text: "+ Add note" });
rendered.createDiv({ cls: "csv-notes-empty", text: "+ add note" });
}
};
renderMarkdown();
@ -463,16 +460,16 @@ export class NoteExpanderModal extends Modal {
const enterEdit = () => {
if (isEditing) return;
isEditing = true;
rendered.style.display = "none";
editorWrap.style.display = "flex";
rendered.classList.add("is-hidden");
editorWrap.classList.remove("is-hidden");
ta!.value = currentText;
ta!.focus();
};
const exitEdit = () => {
if (!isEditing) return;
isEditing = false;
editorWrap.style.display = "none";
rendered.style.display = "";
editorWrap.classList.add("is-hidden");
rendered.classList.remove("is-hidden");
currentText = ta!.value;
renderMarkdown();
};
@ -501,13 +498,20 @@ export class NoteExpanderModal extends Modal {
if (this.onDelete) {
const titleVal = String(this.row[this.titleCol ?? this.headers[0]] ?? "").trim();
footer.createEl("button", { cls: "csv-expander-delete-btn", text: "Delete" })
.addEventListener("click", () => {
const label = titleVal || "this entry";
if (!window.confirm(`Delete "${label}"? This can't be undone.`)) return;
const deleteBtn = footer.createEl("button", {
cls: "csv-expander-delete-btn", text: "Delete",
attr: { title: `Delete "${titleVal || "this entry"}"? This can't be undone.` },
});
deleteBtn.addEventListener("click", () => {
if (deleteBtn.hasClass("confirm")) {
this.onDelete!();
this.close();
});
return;
}
deleteBtn.addClass("confirm");
deleteBtn.setText("Confirm?");
window.setTimeout(() => { if (deleteBtn.isConnected) { deleteBtn.removeClass("confirm"); deleteBtn.setText("Delete"); } }, 3000);
});
}
const rightBtns = footer.createDiv({ cls: "csv-expander-footer-right" });
@ -581,22 +585,9 @@ export class SearchModal extends Modal {
// underneath to near-black, which hides the filter results — the
// user types but can't see the table updating. Tag the container so
// a CSS rule (`.mod-csv-search-bg .modal-bg`) zeroes out the dim.
// Also defensively clear the inline opacity/background in case the
// CSS rule loses the cascade to a later !important. Tap-outside still
// closes because the bg element retains its click handler.
// Tap-outside still closes because the bg element retains its click
// handler.
this.containerEl.addClass("mod-csv-search-bg");
const clearBg = () => {
const bg = this.containerEl.querySelector<HTMLElement>(".modal-bg");
if (bg) {
bg.style.opacity = "0";
bg.style.background = "transparent";
}
};
clearBg();
// Obsidian sometimes restyles the bg after onOpen runs; redo it on
// the next frame and a tick later for good measure.
requestAnimationFrame(clearBg);
setTimeout(clearBg, 50);
// visualViewport pinning — same trick as NoteExpanderModal. Without
// it, Obsidian centers the modal in window.innerHeight and the iOS
@ -680,7 +671,7 @@ export class SearchModal extends Modal {
// Autofocus after one frame so iOS reliably opens the keyboard
// (focus called synchronously sometimes gets dropped on modal-open).
requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
input.focus();
// Caret at the end of existing query so user can extend or backspace.
input.setSelectionRange(input.value.length, input.value.length);
@ -823,7 +814,7 @@ export class FileConfigModal extends Modal {
colSection.createEl("label", { text: "Columns", cls: "csv-modal-label" });
colSection.createEl("p", {
cls: "csv-modal-hint",
text: "Type and Function are each exclusive per column — picking one clears it from whichever column held it before. \"auto\" means it's already doing that by column name, with nothing saved here yet. Card field is independent and can combine with anything. Checkbox columns get a \"Clean up\" action that rewrites every row to strict 1/0 right away — not deferred to Save.",
text: "Type and function are each exclusive per column — picking one clears it from whichever column held it before. \"auto\" means it's already doing that by column name, with nothing saved here yet. Card field is independent and can combine with anything. Checkbox columns get a \"clean up\" action that rewrites every row to strict 1/0 right away — not deferred to save.",
});
type ColType = "text" | "checkbox" | "categorical" | "date";
@ -972,7 +963,7 @@ export class FileConfigModal extends Modal {
// ── Functions Table ──
const funcSection = contentEl.createDiv({ cls: "csv-modal-section", attr: { style: "margin-top: 24px;" } });
funcSection.createEl("h3", { text: "Column Functions", cls: "csv-modal-h3" });
funcSection.createEl("h3", { text: "Column functions", cls: "csv-modal-h3" });
funcSection.createEl("p", {
cls: "csv-modal-desc",
text: "Assign specific roles to your columns. 'auto' means it's already doing that by column name. Selecting '— none —' disables the function entirely."
@ -1058,7 +1049,7 @@ export class FileConfigModal extends Modal {
const addColWrap = colSection.createDiv({ cls: "csv-modal-add-column" });
const addColInput = addColWrap.createEl("input", { cls: "csv-modal-input", type: "text", placeholder: "New column name" });
const addColBtn = addColWrap.createEl("button", { cls: "csv-modal-cancel", text: "+ Add column" });
const addColBtn = addColWrap.createEl("button", { cls: "csv-modal-cancel", text: "+ add column" });
const doAdd = () => {
const err = this.onAddColumn(addColInput.value);
if (err) { new Notice(err); return; }

View file

@ -20,7 +20,7 @@ import { App, TFile, MarkdownPostProcessorContext } from "obsidian";
* the obsidian stub, which breaks cross-bundle instanceof identity.
*/
function asFile(f: unknown): TFile | null {
return f && typeof f === "object" && "basename" in (f as object) ? (f as TFile) : null;
return f && typeof f === "object" && "basename" in (f) ? (f as TFile) : null;
}
import { CSVRow } from "./types";
import { parseCSV, resolvePath } from "./utils";
@ -52,7 +52,7 @@ export async function renderRandomCard(app: App, source: string, el: HTMLElement
headers = parsed.headers;
rows = parsed.rows;
} catch (e) {
el.createEl("p", { text: `Error reading file: ${e}`, cls: "csv-add-error" });
el.createEl("p", { text: `Error reading file: ${e instanceof Error ? e.message : String(e)}`, cls: "csv-add-error" });
return;
}
if (!rows.length) {

View file

@ -12,14 +12,13 @@ export class CardViewSettingTab extends PluginSettingTab {
constructor(app: App, plugin: CardViewPlugin){super(app,plugin); this.plugin=plugin;}
display(): void {
const {containerEl}=this; containerEl.empty();
containerEl.createEl("h2",{text:"DataDeck"});
new Setting(containerEl).setName("Default view mode")
.addDropdown(d=>d.addOption("kanban-genre","Kanban").addOption("table","Table")
.setValue(this.plugin.settings.defaultMode)
.onChange(async v=>{ this.plugin.settings.defaultMode=v as ViewMode; await this.plugin.saveSettings(); }));
new Setting(containerEl).setName("Status column name")
.addText(t=>t.setValue(this.plugin.settings.statusColumn).onChange(async v=>{ this.plugin.settings.statusColumn=v; await this.plugin.saveSettings(); }));
new Setting(containerEl).setName("Category/Genre column name")
new Setting(containerEl).setName("Category/genre column name")
.addText(t=>t.setValue(this.plugin.settings.categoryColumn).onChange(async v=>{ this.plugin.settings.categoryColumn=v; await this.plugin.saveSettings(); }));
new Setting(containerEl).setName("Notes column names").setDesc("Comma-separated.")
.addText(t=>t.setValue(this.plugin.settings.notesColumns.join(", ")).onChange(async v=>{ this.plugin.settings.notesColumns=v.split(",").map(s=>s.trim()); await this.plugin.saveSettings(); }));
@ -33,7 +32,7 @@ export class CardViewSettingTab extends PluginSettingTab {
.setDesc("Show residency / tax day-gauges in the travel view.")
.addToggle(t=>t.setValue(this.plugin.settings.showResidency!==false).onChange(async v=>{ this.plugin.settings.showResidency=v; await this.plugin.saveSettings(); }));
containerEl.createEl("h3",{text:"Residency rules"});
new Setting(containerEl).setName("Residency rules").setHeading();
containerEl.createEl("p",{cls:"setting-item-description",text:"Each rule counts days in the listed countries within the window, minus exempt visa rows, against the threshold. Counts confirmed trips only. Indicators, not legal/tax advice."});
const rrWrap = containerEl.createDiv({cls:"csv-rr-wrap"});
this.renderResidencyRules(rrWrap);
@ -52,7 +51,9 @@ export class CardViewSettingTab extends PluginSettingTab {
label.addEventListener("input", () => { rule.label = label.value; save(); });
const del = head.createEl("button", { cls: "csv-rr-del", text: "✕" });
del.setAttr("aria-label", "Remove rule");
del.addEventListener("click", async () => { rules.splice(i, 1); await this.plugin.saveSettings(); this.renderResidencyRules(wrap); });
del.addEventListener("click", () => {
void (async () => { rules.splice(i, 1); await this.plugin.saveSettings(); this.renderResidencyRules(wrap); })();
});
const grid = card.createDiv({ cls: "csv-rr-grid" });
const field = (lbl: string, value: string, onChange: (v: string) => void, ph = "") => {
@ -89,10 +90,12 @@ export class CardViewSettingTab extends PluginSettingTab {
});
const btns = wrap.createDiv({ cls: "csv-rr-btns" });
btns.createEl("button", { cls: "csv-rr-add", text: "+ Add rule" }).addEventListener("click", async () => {
rules.push({ label: "New rule", scope: { countries: [] }, window: { type: "calendar-year" }, threshold: 183 });
await this.plugin.saveSettings();
this.renderResidencyRules(wrap);
btns.createEl("button", { cls: "csv-rr-add", text: "+ add rule" }).addEventListener("click", () => {
void (async () => {
rules.push({ label: "New rule", scope: { countries: [] }, window: { type: "calendar-year" }, threshold: 183 });
await this.plugin.saveSettings();
this.renderResidencyRules(wrap);
})();
});
}
}

View file

@ -88,7 +88,7 @@ export function buildAggregate(sources: AggSource[]): Aggregate {
src.rows.forEach(srcRow => {
const canon: CSVRow = {};
for (const col of CANON) {
canon[col] = map[col] ? (srcRow[map[col] as string] ?? "") : "";
canon[col] = map[col] ? (srcRow[map[col]] ?? "") : "";
}
if (!map.Project) canon.Project = src.basename;
rows.push(canon);
@ -123,7 +123,7 @@ function parseBlockSource(source: string): TasksBlockOptions {
}
function asFile(f: unknown): TFile | null {
return f && typeof f === "object" && "basename" in (f as object) ? (f as TFile) : null;
return f && typeof f === "object" && "basename" in (f) ? (f as TFile) : null;
}
// ── The block host ───────────────────────────────────────────────────────────
@ -162,29 +162,31 @@ class TasksBlockHost extends MarkdownRenderChild {
get contentEl(): HTMLElement { return this.containerEl; }
private get asView(): CardView { return this as unknown as CardView; }
async onload(): Promise<void> {
onload(): void {
this.containerEl.addClass("csv-inline-view", "csv-tasks-block");
if (!this.opts.folder && !this.opts.files.length) {
this.renderError(`Give a "folder:" line ("/" = whole vault) and/or a "files:" list.`);
return;
}
await this.reload();
this.renderView();
// Re-sync when any source CSV changes underneath us; skip our own saves
// (text equality) and mid-edit states (doSave reconciles those).
this.registerEvent(this.app.vault.on("modify", async (f) => {
const src = this.sources.find(s => s.file.path === f.path);
if (!src || this.saveTimer) return;
const text = await this.app.vault.read(src.file);
if (text === src.lastText) return;
src.lastText = text;
const parsed = parseCSV(text);
src.headers = parsed.headers;
src.rows = parsed.rows;
this.rebuild();
void (async () => {
await this.reload();
this.renderView();
}));
// Re-sync when any source CSV changes underneath us; skip our own saves
// (text equality) and mid-edit states (doSave reconciles those).
this.registerEvent(this.app.vault.on("modify", async (f) => {
const src = this.sources.find(s => s.file.path === f.path);
if (!src || this.saveTimer) return;
const text = await this.app.vault.read(src.file);
if (text === src.lastText) return;
src.lastText = text;
const parsed = parseCSV(text);
src.headers = parsed.headers;
src.rows = parsed.rows;
this.rebuild();
this.renderView();
}));
})();
}
onunload(): void {

View file

@ -175,7 +175,7 @@ export async function renderTravel(
mapWrap.empty();
if (svg) injectMap(mapWrap, svg, model, select);
else mapWrap.createDiv({ cls: "csv-tv-map-loading", text: "World map asset not found (world-map.svg)." });
} catch (_e) {
} catch {
mapWrap.empty();
mapWrap.createDiv({ cls: "csv-tv-map-loading", text: "Couldn't load world map." });
}
@ -230,7 +230,8 @@ function renderStats(root: HTMLElement, m: TravelModel): void {
function injectMap(wrap: HTMLElement, svg: string, m: TravelModel, select: (iso: string | null) => void): void {
const box = wrap.createDiv({ cls: "csv-tv-map" });
box.innerHTML = svg;
const svgEl = new DOMParser().parseFromString(svg, "image/svg+xml").documentElement;
box.appendChild(svgEl);
box.querySelectorAll<SVGPathElement>(".country-path").forEach(p => {
const iso = (p.getAttribute("data-iso") || "").toUpperCase();
p.classList.remove("cp-unvisited", "cp-confirmed", "cp-inferred");
@ -266,11 +267,15 @@ function injectMap(wrap: HTMLElement, svg: string, m: TravelModel, select: (iso:
try {
const r = p.getBoundingClientRect();
if (r.width && r.height && Math.max(r.width, r.height) < 12) p.classList.add("cp-tiny");
} catch (_e) { /* not measurable yet — skip, no halo */ }
} catch { /* not measurable yet — skip, no halo */ }
});
const legend = wrap.createDiv({ cls: "csv-tv-map-legend" });
legend.createSpan({ cls: "csv-tv-leg" }).innerHTML = `<span class="csv-tv-dot cp-confirmed"></span> Confirmed`;
legend.createSpan({ cls: "csv-tv-leg" }).innerHTML = `<span class="csv-tv-dot cp-inferred"></span> Photo evidence`;
const confirmedLeg = legend.createSpan({ cls: "csv-tv-leg" });
confirmedLeg.createSpan({ cls: "csv-tv-dot cp-confirmed" });
confirmedLeg.appendText(" Confirmed");
const inferredLeg = legend.createSpan({ cls: "csv-tv-leg" });
inferredLeg.createSpan({ cls: "csv-tv-dot cp-inferred" });
inferredLeg.appendText(" Photo evidence");
}
/**
@ -431,7 +436,7 @@ function renderTimeline(root: HTMLElement, m: TravelModel, select: (iso: string
}
}
const leg = wrap.createDiv({ cls: "csv-tv-tl-legend" });
leg.setText("Confirmed (solid) · Photo inferred (outlined)");
leg.setText("Confirmed (solid) · photo inferred (outlined)");
}
function sortByDateDesc<T extends { date_entered: string }>(arr: T[]): T[] {

View file

@ -336,8 +336,6 @@ export function showSelectPicker(
// for backward compatibility.
document.body.querySelectorAll(".csv-select-picker").forEach(el => el.remove());
const picker = document.body.createDiv({ cls: "csv-select-picker" });
picker.style.position = "fixed";
picker.style.zIndex = "9999";
// Anchor below the chip by default, but flip above when there isn't room
// below — keeps the dropdown inside the viewport on the bottom edge.
@ -481,7 +479,7 @@ export function showSelectPicker(
// and nothing happens. On desktop the scroll/resize dismissal genuinely
// fixes the picker-floating-detached-from-anchor case, so keep it there.
const isTouch = typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches;
setTimeout(() => {
window.setTimeout(() => {
document.addEventListener("mousedown", onOutside);
if (!isTouch) {
window.addEventListener("scroll", onScroll, true);

View file

@ -27,7 +27,7 @@ async function ankiInvoke(action: string, params: Record<string, unknown>): Prom
body: JSON.stringify({ action, version: ANKI_CONNECT_VERSION, params }),
throw: false,
});
} catch (e) {
} catch {
// requestUrl rejects on connection refused — Anki closed or AnkiConnect
// not installed. Rethrow with a message the user can act on.
throw new Error("Couldn't reach Anki. Is the desktop app open with the AnkiConnect add-on installed?");

View file

@ -57,8 +57,8 @@ export interface LinearFit { slope: number; intercept: number; r2: number; }
export function linearFit(pts: { x: number; y: number }[]): LinearFit | null {
const n = pts.length;
if (n < 2) return null;
let sx = 0, sy = 0, sxx = 0, sxy = 0, syy = 0;
for (const p of pts) { sx += p.x; sy += p.y; sxx += p.x * p.x; sxy += p.x * p.y; syy += p.y * p.y; }
let sx = 0, sy = 0, sxx = 0, sxy = 0;
for (const p of pts) { sx += p.x; sy += p.y; sxx += p.x * p.x; sxy += p.x * p.y; }
const denom = n * sxx - sx * sx;
if (denom === 0) return null;
const slope = (n * sxy - sx * sy) / denom;
@ -391,7 +391,7 @@ export function buildChartConfig(spec: ChartSpec, colors: ChartColors): BuiltCha
},
},
},
} as ChartConfiguration["options"],
},
};
return { config, fitText, formulaError };
}
@ -569,7 +569,7 @@ export function buildBarConfig(data: BarData, xLabel: string, yLabel: string, co
plugins: {
legend: { display: multi, labels: { color: colors.muted, boxWidth: 12 } },
},
} as ChartConfiguration["options"],
},
};
}

View file

@ -117,18 +117,18 @@ export async function renderDashboard(view: CardView, container: HTMLElement): P
// ── Today's Habits ────────────────────────────────────────────────────────
if (currentRow) {
const habitsSection = container.createDiv({ cls: "csv-dash-habits" });
habitsSection.createEl("h3", { text: view.selectedDate === today ? "Today" : view.selectedDate!, cls: "csv-dash-section-title" });
habitsSection.createEl("h3", { text: view.selectedDate === today ? "Today" : view.selectedDate, cls: "csv-dash-section-title" });
const habitsGrid = habitsSection.createDiv({ cls: "csv-dash-habits-grid" });
habitCols.forEach(h => {
const isChecked = view.isTruthy(currentRow![h]);
const isChecked = view.isTruthy(currentRow[h]);
const habitEl = habitsGrid.createDiv({ cls: `csv-dash-habit ${isChecked ? "checked" : ""}` });
const checkbox = habitEl.createEl("button", { cls: "csv-dash-habit-check", text: isChecked ? "●" : "○" });
habitEl.createSpan({ cls: "csv-dash-habit-label", text: h });
checkbox.addEventListener("click", () => {
currentRow![h] = isChecked ? "0" : "1";
currentRow[h] = isChecked ? "0" : "1";
view.scheduleSave();
// Toggling a habit on the current day shouldn't reset dashboard
// scroll — the user may have been looking at habit stats below
@ -138,7 +138,7 @@ export async function renderDashboard(view: CardView, container: HTMLElement): P
});
// Habits done count
const doneCount = habitCols.filter(h => view.isTruthy(currentRow![h])).length;
const doneCount = habitCols.filter(h => view.isTruthy(currentRow[h])).length;
habitsSection.createDiv({ cls: "csv-dash-habits-count", text: `${doneCount} of ${habitCols.length} complete` });
// Notes preview
@ -269,7 +269,19 @@ export async function renderDashboard(view: CardView, container: HTMLElement): P
// Format stats like Dataview: "105 days logged · 2.0 avg/day · 0 perfect days · current streak 8d · best streak 90d"
const statsBar = statsSection.createDiv({ cls: "csv-dash-stats-bar" });
statsBar.innerHTML = `<strong>${totalDays}</strong> days logged · <strong>${avgPerDay}</strong> avg/day · <strong>${perfectDays}</strong> perfect days · current streak <strong>${currentStreak}d</strong> · best streak <strong>${bestStreak}d</strong>`;
const addStat = (value: string, label: string) => {
statsBar.createEl("strong", { text: value });
statsBar.appendText(` ${label}`);
};
addStat(String(totalDays), "days logged");
statsBar.appendText(" · ");
addStat(String(avgPerDay), "avg/day");
statsBar.appendText(" · ");
addStat(String(perfectDays), "perfect days");
statsBar.appendText(" · current streak ");
statsBar.createEl("strong", { text: `${currentStreak}d` });
statsBar.appendText(" · best streak ");
statsBar.createEl("strong", { text: `${bestStreak}d` });
// ── Per-habit cards ───────────────────────────────────────────────────────
const cardsSection = container.createDiv({ cls: "csv-dash-cards-section" });
@ -495,5 +507,9 @@ function renderHabitTimeline(view: CardView, container: HTMLElement, sortedRows:
}
const statsEl = timelineSection.createDiv({ cls: "csv-dash-timeline-stats" });
statsEl.innerHTML = `<strong>${doneDays}</strong> of ${totalEntries} in ${view.timelineYear} · current streak <strong>${habitStreak}d</strong> · best streak <strong>${habitBestStreak}d</strong>`;
statsEl.createEl("strong", { text: String(doneDays) });
statsEl.appendText(` of ${totalEntries} in ${view.timelineYear} · current streak `);
statsEl.createEl("strong", { text: `${habitStreak}d` });
statsEl.appendText(" · best streak ");
statsEl.createEl("strong", { text: `${habitBestStreak}d` });
}

View file

@ -166,7 +166,7 @@ function renderKanbanCard(view: CardView, container: HTMLElement, row: CSVRow, s
text: hasNotesFile ? "📄" : "+",
title: hasNotesFile ? "Open notes file" : "Create notes file",
});
notesIconBtn.addEventListener("click", e => { e.stopPropagation(); view.openOrCreateNotes(row); });
notesIconBtn.addEventListener("click", e => { e.stopPropagation(); void view.openOrCreateNotes(row); });
const sub = view.getSubtitle(row);
if (sub) card.createDiv({cls:"csv-kanban-card-sub", text:sub});
@ -205,7 +205,7 @@ function renderKanbanCard(view: CardView, container: HTMLElement, row: CSVRow, s
const hasInlineNotes = !!(notesCol && row[notesCol]?.trim());
// The preview is itself the editor affordance — clicking opens the
// inline textarea. When there's no note yet, render a quiet "+ Add note"
// inline textarea. When there's no note yet, render a quiet "+ add note"
// placeholder in the same slot so the click target is discoverable
// without needing a separate "Edit note" button.
const notesPreviewEl = card.createDiv({cls:"csv-kanban-notes-preview"});
@ -215,26 +215,25 @@ function renderKanbanCard(view: CardView, container: HTMLElement, row: CSVRow, s
notesPreviewEl.title = "Click to edit";
} else {
notesPreviewEl.addClass("csv-kanban-notes-preview--empty");
if (notesCol) notesPreviewEl.setText("+ Add note");
if (notesCol) notesPreviewEl.setText("+ add note");
}
const notesEditorEl = card.createDiv({cls:"csv-kanban-notes-editor"});
notesEditorEl.style.display = "none";
const notesEditorEl = card.createDiv({cls:"csv-kanban-notes-editor is-hidden"});
const openInlineEditor = () => {
// Save scroll position of the content area so we can restore it on close
const contentArea = view.contentEl.querySelector(".csv-content-area") as HTMLElement | null;
const contentArea = view.contentEl.querySelector<HTMLElement>(".csv-content-area");
const scrollLeft = contentArea?.scrollLeft ?? 0;
const scrollTop = contentArea?.scrollTop ?? 0;
notesPreviewEl.style.display = "none";
notesEditorEl.style.display = "block";
notesPreviewEl.classList.add("is-hidden");
notesEditorEl.classList.remove("is-hidden");
notesEditorEl.empty();
const ta = notesEditorEl.createEl("textarea", {cls:"csv-notes-textarea"});
ta.value = (notesCol ? row[notesCol] : "") ?? "";
ta.addEventListener("click", e => e.stopPropagation());
ta.addEventListener("mousedown", e => e.stopPropagation());
ta.addEventListener("input", () => { ta.style.height="auto"; ta.style.height=ta.scrollHeight+"px"; });
ta.addEventListener("input", () => { ta.style.removeProperty("height"); ta.style.height=ta.scrollHeight+"px"; });
// One close per open: Escape discards (closes with the original text) and
// the hide triggers a blur, which must not commit a second time — without
// the guard, Escape saved the edit anyway via the follow-up blur.
@ -259,8 +258,8 @@ function renderKanbanCard(view: CardView, container: HTMLElement, row: CSVRow, s
// Size to content after a frame. Reading scrollHeight inline (before
// layout) returns ~0, which made the height clamp to the 120 px floor
// regardless of how long the note actually is.
requestAnimationFrame(() => {
ta.style.height = "auto";
window.requestAnimationFrame(() => {
ta.style.removeProperty("height");
ta.style.height = Math.max(120, ta.scrollHeight) + "px";
});
};
@ -269,18 +268,18 @@ function renderKanbanCard(view: CardView, container: HTMLElement, row: CSVRow, s
// Only dirty the file on a real change — open-then-close shouldn't queue
// a vault write (and the sync churn that follows).
if (notesCol && newVal !== (row[notesCol] ?? "")) { row[notesCol]=newVal; view.scheduleSave(); }
notesEditorEl.style.display = "none";
notesPreviewEl.style.display = "";
notesEditorEl.classList.add("is-hidden");
notesPreviewEl.classList.remove("is-hidden");
if (newVal.trim()) {
const plain = newVal.replace(/#{1,6}\s/g,"").replace(/[*_>`]/g,"").replace(/\n+/g," ").trim();
notesPreviewEl.setText(plain.slice(0,120) + (plain.length > 120 ? "…" : ""));
notesPreviewEl.removeClass("csv-kanban-notes-preview--empty");
notesPreviewEl.title = "Click to edit";
} else {
// Restore the "+ Add note" placeholder rather than leaving an empty,
// Restore the "+ add note" placeholder rather than leaving an empty,
// invisible click target. Matches initial render exactly.
notesPreviewEl.addClass("csv-kanban-notes-preview--empty");
notesPreviewEl.setText(notesCol ? "+ Add note" : "");
notesPreviewEl.setText(notesCol ? "+ add note" : "");
notesPreviewEl.removeAttribute("title");
}
// Restore scroll position after the DOM settles
@ -288,15 +287,15 @@ function renderKanbanCard(view: CardView, container: HTMLElement, row: CSVRow, s
if (contentArea) {
contentArea.scrollLeft = scrollLeft;
contentArea.scrollTop = scrollTop;
requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
contentArea.scrollLeft = scrollLeft;
contentArea.scrollTop = scrollTop;
requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
contentArea.scrollLeft = scrollLeft;
contentArea.scrollTop = scrollTop;
});
});
setTimeout(() => {
window.setTimeout(() => {
contentArea.scrollLeft = scrollLeft;
contentArea.scrollTop = scrollTop;
}, 50);

View file

@ -14,7 +14,6 @@ export function renderLibrary(view: CardView, container: HTMLElement): void {
const cc = effectiveGroupCol(view);
const sc = view.getStatusCol();
const titleCol = view.titleKey() ?? view.headers[0];
const authorCol = view.authorKey();
if (!cc) {
container.createEl("p", { text: `No groupable column found.`, cls: "csv-empty-state" });
@ -53,9 +52,9 @@ export function renderLibrary(view: CardView, container: HTMLElement): void {
if (hasDone || hasInProgress) {
statusSelect.createEl("option", { text: "───────", value: "", attr: { disabled: "true" } });
if (hasDone) statusSelect.createEl("option", { text: "✓ Done", value: "__done__" });
if (hasInProgress) statusSelect.createEl("option", { text: "◐ In Progress", value: "__inprogress__" });
statusSelect.createEl("option", { text: "○ Not Started", value: "__notstarted__" });
if (hasDone) statusSelect.createEl("option", { text: "✓ done", value: "__done__" });
if (hasInProgress) statusSelect.createEl("option", { text: "◐ in progress", value: "__inprogress__" });
statusSelect.createEl("option", { text: "○ not started", value: "__notstarted__" });
}
if (allStatuses.size > 0) {

View file

@ -101,15 +101,15 @@ export function renderTable(view: CardView, container: HTMLElement): void {
const at = tr.createEl("td",{cls:"csv-table-action"});
const hasFile = view.notesFileExists(row);
at.createEl("button",{cls:`csv-table-notes-btn ${hasFile?"exists":""}`,text:hasFile?"📄":"✚",title:hasFile?"Open notes":"Create notes"})
.addEventListener("click",()=>view.openOrCreateNotes(row));
at.createEl("button",{cls:"csv-table-del-btn",text:"✕",title:"Delete row (Undo available)"})
.addEventListener("click",()=>void view.openOrCreateNotes(row));
at.createEl("button",{cls:"csv-table-del-btn",text:"✕",title:"Delete row (undo available)"})
.addEventListener("click",()=>view.deleteWithUndo(row));
});
// Detect overflowing cells in one rAF instead of one per row. Single
// querySelectorAll, single forced-layout batch — orders of magnitude
// cheaper than per-row rAF on big files. Skipped entirely on touch.
if (!isTouch) {
requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
tbody.querySelectorAll<HTMLElement>("td:not(.csv-table-notes-cell):not(.csv-table-action)").forEach(cell => {
if (cell.scrollHeight > cell.clientHeight + 1) cell.addClass("csv-cell--clipped");
});

View file

@ -228,7 +228,7 @@ export function renderTasks(view: CardView, container: HTMLElement): void {
// Done toggle (only when there's a status column to flip).
if (statusCol) {
const checkCell = tr.createEl("td", { cls: "csv-tasks-check-cell" });
const box = checkCell.createEl("span", { cls: `csv-tasks-check ${done ? "is-done" : ""}`, text: done ? "✓" : "" });
const box = checkCell.createSpan({ cls: `csv-tasks-check ${done ? "is-done" : ""}`, text: done ? "✓" : "" });
box.setAttr("title", done ? "Mark not done" : "Mark done");
box.addEventListener("click", e => {
e.stopPropagation();

View file

@ -228,17 +228,17 @@ export function renderToolbar(view: CardView, root: HTMLElement): void {
(header) => view.cleanupBooleanColumn(header),
).open();
};
const openBackup = () => view.backupToArchive();
const openAnki = () => syncToAnki(view);
const openBackup = () => { void view.backupToArchive(); };
const openAnki = () => { void syncToAnki(view); };
ctrl.createEl("button", { cls: "csv-cfg-btn csv-cfg-btn-secondary", text: "⚙ Config", title: "Configure this file's columns and views" })
.addEventListener("click", openColumns);
ctrl.createEl("button", { cls: "csv-cfg-btn csv-cfg-btn-secondary", text: "💾 Backup", title: "Copy this file to Archive/ with today's date" })
ctrl.createEl("button", { cls: "csv-cfg-btn csv-cfg-btn-secondary", text: "💾 Backup", title: "Copy this file to archive/ with today's date" })
.addEventListener("click", openBackup);
ctrl.createEl("button", { cls: "csv-cfg-btn csv-cfg-btn-secondary", text: "🎴 Anki", title: "Sync rows to Anki (needs Anki desktop + AnkiConnect)" })
.addEventListener("click", openAnki);
ctrl.createEl("button",{cls:"csv-add-btn",text:"+ Add"}).addEventListener("click",()=>view.openAddModal());
ctrl.createEl("button",{cls:"csv-add-btn",text:"+ add"}).addEventListener("click",()=>view.openAddModal());
// ⋯ overflow lives after + Add so on mobile (where the secondary buttons
// are hidden) the row reads `[modes] [search] [+ Add] [⋯]` — the primary

View file

@ -1278,6 +1278,8 @@ td .csv-select-chip {
/* ─── Picker dropdown ─────────────────────────────────────────────────────── */
.csv-select-picker {
position: fixed;
z-index: 9999;
background: var(--background-primary);
border: 1px solid var(--csv-border);
border-radius: var(--csv-radius);
@ -1410,6 +1412,11 @@ td .csv-select-chip {
margin-top: 8px;
}
.csv-kanban-notes-editor.is-hidden,
.csv-kanban-notes-preview.is-hidden {
display: none;
}
.csv-kanban-notes-editor .csv-notes-textarea {
width: 100%;
min-height: 120px;
@ -1805,6 +1812,7 @@ td .csv-select-chip {
}
.csv-expander-rendered:hover { background: var(--csv-surface-2); }
.csv-expander-rendered.is-hidden { display: none; }
/* Links/buttons inside the rendered note keep their own cursor and don't
pick up the hover-tint from the editable container. */
.csv-expander-rendered a,
@ -1843,6 +1851,7 @@ td .csv-select-chip {
flex-direction: column;
padding: 16px 20px;
}
.csv-expander-editor.is-hidden { display: none; }
.csv-expander-textarea {
flex: 1;
@ -2274,6 +2283,13 @@ td .csv-select-chip {
color: white;
}
.csv-expander-delete-btn.confirm {
background: var(--csv-red);
border-color: var(--csv-red);
color: white;
font-weight: 600;
}
/* Chip truncation in kanban */
.csv-chip-value {
max-width: 200px;
@ -2793,6 +2809,7 @@ td .csv-select-chip {
.csv-add-trigger:hover { background: var(--background-modifier-hover); }
.csv-add-trigger:active { transform: scale(0.98); }
.csv-add-trigger.is-hidden { display: none; }
/* Expanded card */
.csv-add-card {
@ -2802,6 +2819,7 @@ td .csv-select-chip {
box-shadow: inset 0 0 0 1px transparent;
transition: box-shadow 0.15s;
}
.csv-add-card.is-hidden { display: none; }
/* Update mode (date matches an existing row): subtle accent ring + tinted
title so the user sees they're editing rather than creating. */
@ -2866,6 +2884,7 @@ td .csv-select-chip {
}
.csv-add-row:last-child { border-bottom: none; }
.csv-add-row-custom.is-hidden { display: none; }
.csv-add-row-label {
flex: 0 0 auto;

4
svg.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.svg" {
const content: string;
export default content;
}

View file

@ -34,6 +34,7 @@ export function setupDom() {
HE.createDiv = function (opts, cb) { return this.createEl("div", opts, cb); };
HE.createSpan = function (opts, cb) { return this.createEl("span", opts, cb); };
HE.setText = function (t) { this.textContent = t == null ? "" : String(t); return this; };
HE.appendText = function (t) { this.appendChild(doc.createTextNode(String(t))); return this; };
HE.empty = function () { while (this.firstChild) this.removeChild(this.firstChild); return this; };
HE.addClass = function (...c) { this.classList.add(...c.filter(Boolean)); return this; };
HE.removeClass = function (...c) { this.classList.remove(...c.filter(Boolean)); return this; };
@ -52,6 +53,7 @@ export function setupDom() {
globalThis.document = doc;
globalThis.HTMLElement = window.HTMLElement;
globalThis.Node = window.Node;
globalThis.DOMParser = window.DOMParser;
globalThis.requestAnimationFrame = globalThis.requestAnimationFrame || ((fn) => setTimeout(() => fn(Date.now()), 0));
globalThis.matchMedia = globalThis.matchMedia || (() => ({ matches: false, addEventListener() {}, removeEventListener() {} }));
window.matchMedia = window.matchMedia || globalThis.matchMedia;

View file

@ -12,5 +12,5 @@
"declaration": false,
"noEmit": true
},
"include": ["main.ts"]
"include": ["main.ts", "svg.d.ts"]
}