mirror of
https://github.com/svm0n/datadeck.git
synced 2026-07-22 08:31:46 +00:00
Found by an actual read-through of every 1.1/1.2 file, not keyword search: - src/utils.ts, src/view/chart.ts, esbuild.config.mjs: comments/code named a specific timezone (CEST), keyboard layout (Swedish), and locale code (sv-SE) used only for its date-format side effect — three independent signals triangulating toward the same nationality. Genericized all three; esbuild.config.mjs now formats the build timestamp manually instead of depending on any locale string at all. - main.ts, src/view/table.ts, src/view/toolbar.ts: "iPhone"/"iCloud" framed as the author's own device/sync setup — genericized to "mobile"/ "sync". - main.ts, src/inline-view.ts, src/travel-data.ts, src/travel-view.ts: dangling "see handoff"/"HANDOFF" references to handoff.md, which is gitignored and not part of the public repo. Replaced each with a self-contained inline explanation instead of a pointer nobody can follow. - docs/architecture.md: "Built for the author's book library and habit tracking" softened to describe original use case without first-person ownership framing. - docs/dev-workflow.md: the project-tree diagram was stale (referenced test-mobile-dashboards.mjs and xlsx-to-csv-roundtrip.mjs, both long gone) and garbled (a cut-off local/ bullet with a "hardcoded-path caveat" aside). Rewritten to match the actual current file list. Tests: 115 passed, 0 failed. Typecheck clean. Build unaffected.
1230 lines
55 KiB
TypeScript
1230 lines
55 KiB
TypeScript
import {
|
|
App,
|
|
Plugin,
|
|
FileView,
|
|
WorkspaceLeaf,
|
|
Component,
|
|
MarkdownRenderer,
|
|
Menu,
|
|
Notice,
|
|
TFile,
|
|
normalizePath,
|
|
stringifyYaml,
|
|
} from "obsidian";
|
|
import Papa from "papaparse";
|
|
// Type-only import — erased at compile time, no runtime cost. Used for the
|
|
// chartInstance field type; the actual module is lazy-loaded inside
|
|
// src/view/dashboard.ts, so non-dashboard sessions never load Chart.js.
|
|
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 { isDateCol } from "./src/field-types";
|
|
import { AddEntryModal, NoteExpanderModal, FileConfigModal, SearchModal, PromptModal } from "./src/modals";
|
|
import { renderTravel } from "./src/travel-view";
|
|
import { CardViewSettingTab } from "./src/settings-tab";
|
|
import { renderAddEntryForm } from "./src/add-entry-form";
|
|
import { renderTable } from "./src/view/table";
|
|
import { renderLibrary } from "./src/view/library";
|
|
import { renderKanbanGenre, effectiveGroupCol } from "./src/view/kanban";
|
|
import { renderToolbar, availableModes } from "./src/view/toolbar";
|
|
import { renderRandomCard } from "./src/random-block";
|
|
import { renderDashboard } from "./src/view/dashboard";
|
|
import { renderStats, hasStatsColumns } from "./src/view/stats";
|
|
import { renderChart, hasChartColumns } from "./src/view/chart";
|
|
import { renderFocus } from "./src/view/focus";
|
|
import { renderTasks, hasTaskColumns, taskProjectCol, taskTypeCol, taskPriorityCol } from "./src/view/tasks";
|
|
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;
|
|
|
|
|
|
// ─── View ────────────────────────────────────────────────────────────────────
|
|
|
|
export class CardView extends FileView {
|
|
settings: CardViewSettings;
|
|
headers: string[] = [];
|
|
rows: CSVRow[] = [];
|
|
mode: ViewMode;
|
|
private renderComponent: Component;
|
|
private saveTimer: number | null = null;
|
|
searchQuery: string = "";
|
|
// Callback into the plugin to persist `settings` to data.json.
|
|
// Passed at construction time (see CardViewPlugin.onload) so the view
|
|
// doesn't have to reach back through `(app as any).plugins.plugins[...]`
|
|
// to find its own plugin instance.
|
|
persistSettings: () => Promise<void>;
|
|
|
|
constructor(leaf: WorkspaceLeaf, settings: CardViewSettings, persistSettings: () => Promise<void>) {
|
|
super(leaf);
|
|
this.settings = settings;
|
|
this.persistSettings = persistSettings;
|
|
this.mode = settings.defaultMode;
|
|
this.renderComponent = new Component();
|
|
this.renderComponent.load();
|
|
}
|
|
|
|
getViewType() { return CARD_VIEW_TYPE; }
|
|
getDisplayText() { return this.file?.basename ?? "Card View"; }
|
|
getIcon() { return "table"; }
|
|
|
|
// ── File I/O ───────────────────────────────────────────────────────────────
|
|
|
|
// The CSV text as we last read or wrote it. The anchor for sync safety:
|
|
// an on-disk value that diverges from this means another writer (device,
|
|
// tab, iCloud) touched the file since we last agreed with it.
|
|
private lastDiskContent: string | null = null;
|
|
|
|
onload(): void {
|
|
super.onload();
|
|
// Re-sync when the open file changes underneath us — the inline csv-view
|
|
// block always did this; the full view showed stale data until reopen.
|
|
this.registerEvent(this.app.vault.on("modify", (f) => {
|
|
if (!this.file || f.path !== this.file.path) return;
|
|
void this.syncFromDisk();
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Refresh from disk after an external change. Skips our own writes
|
|
* (content equality with lastDiskContent) and mid-edit states — when a
|
|
* debounced save is pending, doSave's conflict stash reconciles instead,
|
|
* so the user's in-flight edits aren't yanked out from under them.
|
|
*/
|
|
private async syncFromDisk(): Promise<void> {
|
|
if (!this.file) return;
|
|
const text = await this.app.vault.read(this.file);
|
|
if (text === this.lastDiskContent) return;
|
|
if (this.saveTimer) return;
|
|
this.lastDiskContent = text;
|
|
const parsed = parseCSV(text);
|
|
this.headers = parsed.headers;
|
|
this.rows = parsed.rows;
|
|
this.renderViewPreservingScroll();
|
|
}
|
|
|
|
async onLoadFile(file: TFile): Promise<void> {
|
|
try {
|
|
const text = await this.app.vault.read(file);
|
|
const parsed = parseCSV(text);
|
|
this.headers = parsed.headers;
|
|
this.rows = parsed.rows;
|
|
this.lastDiskContent = text;
|
|
} catch (e) {
|
|
console.error("CardView load error", e);
|
|
this.headers = []; this.rows = []; this.lastDiskContent = null;
|
|
// Surface to the user — silent failure leaves an empty view with no clue why.
|
|
new Notice(`Couldn't read ${file.name}: ${e instanceof Error ? e.message : String(e)}`, 8000);
|
|
}
|
|
// Apply per-file default mode if set, or auto-detect based on columns
|
|
if (this.file && this.settings.fileConfigs[this.file.path]?.defaultMode) {
|
|
this.mode = this.settings.fileConfigs[this.file.path].defaultMode!;
|
|
} else if (this.isTravelFile()) {
|
|
// Travel logs (country/date/source columns) get the map view by default.
|
|
// Checked before the date-column rule since they also have date columns.
|
|
this.mode = "travel";
|
|
} else if (this.hasDateColumn()) {
|
|
// Auto-default to dashboard if date column detected
|
|
this.mode = "dashboard";
|
|
} else {
|
|
this.mode = this.settings.defaultMode;
|
|
}
|
|
// Guard: if the resolved mode requires a column this file doesn't have,
|
|
// fall back to "table" so we never land on a broken empty-state screen.
|
|
// Cards/Kanban only need *some* groupable column (per-file pick →
|
|
// category → auto fallback; see effectiveGroupCol in view/kanban.ts).
|
|
const needsCategory = this.mode === "kanban-genre" || this.mode === "library";
|
|
const needsDate = this.mode === "dashboard";
|
|
if ((needsCategory && !effectiveGroupCol(this)) || (needsDate && !this.hasDateColumn())
|
|
|| (this.mode === "travel" && !this.isTravelFile())
|
|
|| (this.mode === "stats" && !hasStatsColumns(this))
|
|
|| (this.mode === "chart" && !hasChartColumns(this))
|
|
|| (this.mode === "tasks" && !hasTaskColumns(this))) {
|
|
this.mode = "table";
|
|
}
|
|
this.selectedDate = null; // Reset selected date when loading new file
|
|
this.focusIndex = 0; // Focus view starts at the first entry per file
|
|
this.tableSortCol = null; // Manual column sort doesn't carry across files
|
|
this.renderView();
|
|
}
|
|
|
|
async onUnloadFile(_file: TFile): Promise<void> {
|
|
if (this.chartInstance) { this.chartInstance.destroy(); this.chartInstance = null; }
|
|
this.headers = []; this.rows = []; this.contentEl.empty();
|
|
}
|
|
|
|
scheduleSave(): void {
|
|
if (this.saveTimer) window.clearTimeout(this.saveTimer);
|
|
this.saveTimer = window.setTimeout(() => this.doSave(), 600);
|
|
}
|
|
|
|
private async doSave(): Promise<void> {
|
|
if (!this.file) return;
|
|
try {
|
|
const csv = Papa.unparse(this.rows, { columns: this.headers });
|
|
// Sync safety: this is a whole-file overwrite from in-memory rows. If
|
|
// the file changed on disk since we last agreed with it, stash that
|
|
// version to Archive/ first so the other writer's edits survive —
|
|
// last-write-wins at the file level used to silently eat them.
|
|
try {
|
|
const onDisk = await this.app.vault.read(this.file);
|
|
if (this.lastDiskContent !== null && onDisk !== this.lastDiskContent && onDisk !== csv) {
|
|
await stashSyncConflict(this.app, this.file, onDisk);
|
|
}
|
|
} catch { /* unreadable right now — proceed with the write */ }
|
|
this.lastDiskContent = csv;
|
|
await this.app.vault.modify(this.file, csv);
|
|
} catch (e) {
|
|
console.error("CardView save error", e);
|
|
// Surface — the debounced save is invisible, so a permission / iCloud
|
|
// sync conflict otherwise looks like "my edits stuck" until reload.
|
|
new Notice(`Couldn't save ${this.file.name}: ${e instanceof Error ? e.message : String(e)}`, 8000);
|
|
}
|
|
}
|
|
|
|
// ── Per-file config ────────────────────────────────────────────────────────
|
|
|
|
get fileCfg(): FileConfig {
|
|
return this.file ? (this.settings.fileConfigs[this.file.path] ?? {}) : {};
|
|
}
|
|
|
|
saveFileCfg(cfg: FileConfig): void {
|
|
if (!this.file) return;
|
|
this.settings.fileConfigs[this.file.path] = cfg;
|
|
// Fire-and-forget — saveSettings is debounce-safe inside Obsidian.
|
|
void this.persistSettings();
|
|
}
|
|
|
|
// ── Title highlight ────────────────────────────────────────────────────────
|
|
// Per-file, keyed by the title-column value (like collapsedGroups) rather
|
|
// than a row index/id — the CSV has no stable identity column, and a
|
|
// value-based key is resilient to row reordering/sort changes.
|
|
|
|
isHighlighted(row: CSVRow): boolean {
|
|
return (this.fileCfg.highlightedTitles ?? []).includes(this.getTitle(row));
|
|
}
|
|
|
|
toggleHighlight(row: CSVRow): void {
|
|
const title = this.getTitle(row);
|
|
const cfg = this.fileCfg;
|
|
const list = cfg.highlightedTitles ? [...cfg.highlightedTitles] : [];
|
|
const idx = list.indexOf(title);
|
|
if (idx >= 0) list.splice(idx, 1); else list.push(title);
|
|
cfg.highlightedTitles = list;
|
|
this.saveFileCfg(cfg);
|
|
}
|
|
|
|
// ── Column structure ───────────────────────────────────────────────────────
|
|
// Add/remove a CSV column itself (not just its role). Used by the ⚙ Columns
|
|
// modal. `doSave` writes with `columns: this.headers`, so pushing/filtering
|
|
// headers is what actually changes the file's shape on next save.
|
|
|
|
/** Returns an error message on failure, or null on success. */
|
|
addColumn(name: string): string | null {
|
|
const trimmed = name.trim();
|
|
if (!trimmed) return "Column name can't be empty.";
|
|
if (this.headers.some(h => h.toLowerCase() === trimmed.toLowerCase())) return `"${trimmed}" already exists.`;
|
|
this.headers.push(trimmed);
|
|
this.rows.forEach(r => { r[trimmed] = ""; });
|
|
this.scheduleSave();
|
|
this.renderViewPreservingScroll();
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Deletes a column and its data from every row. Also clears any per-file
|
|
* config pointing at it (category/status/notes/anki/card-fields/habits/
|
|
* categorical) so a stale reference doesn't silently break kanban
|
|
* grouping, the Add form, etc. after the column is gone.
|
|
*/
|
|
removeColumn(header: string): void {
|
|
this.headers = this.headers.filter(h => h !== header);
|
|
this.rows.forEach(r => { delete r[header]; });
|
|
const cfg = this.fileCfg;
|
|
if (cfg.categoryColumn === header) cfg.categoryColumn = undefined;
|
|
if (cfg.statusColumn === header) cfg.statusColumn = undefined;
|
|
if (cfg.notesColumn === header) cfg.notesColumn = undefined;
|
|
if (cfg.ankiFrontCol === header) cfg.ankiFrontCol = undefined;
|
|
if (cfg.kanbanGroupCol === header) cfg.kanbanGroupCol = undefined;
|
|
if (cfg.habitColumns) cfg.habitColumns = cfg.habitColumns.filter(h => h !== header);
|
|
if (cfg.cardFields) cfg.cardFields = cfg.cardFields.filter(h => h !== header);
|
|
if (cfg.categoricalColumns) cfg.categoricalColumns = cfg.categoricalColumns.filter(h => h !== header);
|
|
this.saveFileCfg(cfg);
|
|
this.scheduleSave();
|
|
this.renderViewPreservingScroll();
|
|
}
|
|
|
|
// ── Field helpers ──────────────────────────────────────────────────────────
|
|
|
|
// ── Field helpers with fallback chains ────────────────────────────────────
|
|
|
|
// Tries each candidate in order, returns first match found in headers
|
|
resolveCol(candidates: string[]): string | null {
|
|
for (const c of candidates) {
|
|
const found = this.headers.find(h => h.toLowerCase() === c.toLowerCase());
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Role getters: an explicit per-file override wins outright — including the
|
|
// empty string, which the ⚙ Config modal stores for "— no function —" and
|
|
// which must read as *disabled*, not "fall back to name detection" (that's
|
|
// what InlineCardHost already does; the two views must agree on the same
|
|
// fileCfg entry).
|
|
getNotesCol(): string | null {
|
|
if (this.fileCfg.notesColumn !== undefined) {
|
|
return this.fileCfg.notesColumn === "" ? null : this.fileCfg.notesColumn;
|
|
}
|
|
return this.resolveCol(NOTES_COL_ALIASES);
|
|
}
|
|
|
|
isNotesCol(h: string): boolean {
|
|
const notesCol = this.getNotesCol();
|
|
// If we resolved a specific column, only that one qualifies
|
|
if (notesCol) return h === notesCol;
|
|
// Otherwise fall back to global list (shouldn't normally reach here)
|
|
return this.settings.notesColumns.some(n => n.toLowerCase() === h.toLowerCase());
|
|
}
|
|
|
|
isSelectCol(h: string) { return this.settings.selectColumns.some(s => s.toLowerCase()===h.toLowerCase()); }
|
|
|
|
/**
|
|
* Whether a column should render as a dropdown (Add entry / entry editor /
|
|
* mobile add form) rather than free text. Single source of truth: an
|
|
* explicit per-file `categoricalColumns` list wins outright when set;
|
|
* otherwise falls back to the historical auto-detection (a configured
|
|
* select column, or a low-cardinality "pseudo-categorical" one). The
|
|
* title/index column and date columns are never categorical, regardless
|
|
* of the list — a free-text identifier or a date picker, never a select.
|
|
*/
|
|
isCategoricalCol(h: string): boolean {
|
|
const titleCol = this.titleKey() ?? this.headers[0];
|
|
if (h === titleCol || this.isDateCol(h) || isMultiValueColName(h)) return false;
|
|
if (this.fileCfg.categoricalColumns) return this.fileCfg.categoricalColumns.includes(h);
|
|
return this.isSelectCol(h) || looksCategorical(this.getColumnValues(h).length);
|
|
}
|
|
|
|
/** What isCategoricalCol would auto-detect with no per-file override — drives the ⚙ Columns checkbox pre-check. */
|
|
autoDetectCategoricalColumns(): string[] {
|
|
const titleCol = this.titleKey() ?? this.headers[0];
|
|
return this.headers.filter(h => {
|
|
if (h === titleCol || this.isDateCol(h) || isMultiValueColName(h) || this.isNotesCol(h)) return false;
|
|
return this.isSelectCol(h) || looksCategorical(this.getColumnValues(h).length);
|
|
});
|
|
}
|
|
|
|
isDateCol(h: string): boolean {
|
|
if (this.fileCfg.dateColumns?.includes(h)) return true;
|
|
return isDateCol(h);
|
|
}
|
|
|
|
getStatusCol(): string | null {
|
|
if (this.fileCfg.statusColumn !== undefined) {
|
|
if (this.fileCfg.statusColumn === "") return null;
|
|
return this.headers.find(h => h.toLowerCase() === this.fileCfg.statusColumn!.toLowerCase()) ?? null;
|
|
}
|
|
return this.resolveCol(STATUS_COL_ALIASES);
|
|
}
|
|
|
|
getCategoryCol(): string | null {
|
|
if (this.fileCfg.categoryColumn !== undefined) {
|
|
if (this.fileCfg.categoryColumn === "") return null;
|
|
return this.headers.find(h => h.toLowerCase() === this.fileCfg.categoryColumn!.toLowerCase()) ?? null;
|
|
}
|
|
return this.resolveCol(CATEGORY_COL_ALIASES);
|
|
}
|
|
|
|
titleKey(): string | undefined {
|
|
if (this.fileCfg.titleColumn !== undefined) {
|
|
if (this.fileCfg.titleColumn === "") return undefined;
|
|
return this.headers.find(h => h.toLowerCase() === this.fileCfg.titleColumn!.toLowerCase()) ?? undefined;
|
|
}
|
|
return this.resolveCol(TITLE_COL_ALIASES) ?? undefined;
|
|
}
|
|
|
|
authorKey(): string | undefined {
|
|
return this.resolveCol([
|
|
"Author","author","Authors","authors",
|
|
"Director","director",
|
|
"Artist","artist",
|
|
"Creator","creator",
|
|
"By","by",
|
|
]) ?? undefined;
|
|
}
|
|
getTitle(row: CSVRow) { const k=this.titleKey(); return (k?row[k]:row[this.headers[0]])??"—"; }
|
|
getSubtitle(row: CSVRow) { const k=this.authorKey(); return k?row[k]??"":""; }
|
|
getColumnValues(h: string) { return Array.from(new Set(this.rows.map(r=>r[h]??"").filter(Boolean))).sort(); }
|
|
// Image column for card/kanban thumbnails — per-file override (reuses the
|
|
// cardImageColumn config) or detected by name (Image/Cover/Poster/…).
|
|
getImageCol(): string | null {
|
|
if (this.fileCfg.imageColumn !== undefined) {
|
|
if (this.fileCfg.imageColumn === "") return null;
|
|
return this.headers.find(h => h.toLowerCase() === this.fileCfg.imageColumn!.toLowerCase()) ?? null;
|
|
}
|
|
return this.resolveCol(IMAGE_COL_ALIASES);
|
|
}
|
|
|
|
// ── Notes file ─────────────────────────────────────────────────────────────
|
|
|
|
private notesFilePath(row: CSVRow): string {
|
|
const title = sanitizeFilename(this.getTitle(row));
|
|
const csvFolder = this.file?.parent?.path??"";
|
|
const sub = this.settings.notesSubfolder.trim();
|
|
const folder = sub?(csvFolder?`${csvFolder}/${sub}`:sub):csvFolder;
|
|
return normalizePath(`${folder}/${title}.md`);
|
|
}
|
|
|
|
notesFileExists(row: CSVRow) { return !!this.app.vault.getAbstractFileByPath(this.notesFilePath(row)); }
|
|
|
|
/**
|
|
* Remove a row, save, re-render — and offer Undo via a Notice. Restoring
|
|
* preserves the original index, so kanban / table positions don't visually
|
|
* jump on undo. Idempotent: clicking Undo twice is a no-op (the button
|
|
* disables itself after the first click).
|
|
*
|
|
* Used by every delete path (expander modal, kanban right-click, table
|
|
* row button) so deletes have one consistent escape hatch.
|
|
*/
|
|
deleteWithUndo(row: CSVRow): void {
|
|
const idx = this.rows.indexOf(row);
|
|
if (idx < 0) return;
|
|
this.rows.splice(idx, 1);
|
|
this.scheduleSave();
|
|
// In-place edit — the user deleted from where they are; keep them
|
|
// anchored there instead of yanking the view to (0,0).
|
|
this.renderViewPreservingScroll();
|
|
|
|
const title = this.getTitle(row) || "entry";
|
|
const frag = document.createDocumentFragment();
|
|
frag.createSpan({ text: `Deleted “${title}”. ` });
|
|
const undoBtn = frag.createEl("button", { text: "Undo", cls: "csv-notice-undo" });
|
|
const notice = new Notice(frag, 6000);
|
|
undoBtn.addEventListener("click", () => {
|
|
if (undoBtn.hasAttribute("disabled")) return;
|
|
undoBtn.setAttribute("disabled", "true");
|
|
// Clamp insert position — other deletes/adds during the 6s window
|
|
// may have shifted the array; idx may now be past end.
|
|
const insertAt = Math.min(idx, this.rows.length);
|
|
this.rows.splice(insertAt, 0, row);
|
|
this.scheduleSave();
|
|
this.renderViewPreservingScroll();
|
|
notice.hide();
|
|
new Notice(`Restored “${title}”`, 2500);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Open the right-click context menu for a row. Same items everywhere
|
|
* the user can click on a row — kanban card, library card, table row.
|
|
* Previously only the kanban card had this; the parity gap meant
|
|
* power-users in library/table had to use buttons for status changes
|
|
* and the toolbar for delete.
|
|
*/
|
|
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)));
|
|
// 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() ?? "")));
|
|
const sc = this.getStatusCol();
|
|
if (sc) {
|
|
const statuses = this.getColumnValues(sc);
|
|
if (statuses.length) {
|
|
menu.addSeparator();
|
|
statuses.forEach(s => {
|
|
if (s === row[sc]) return;
|
|
menu.addItem(i => i.setTitle(`Mark as: ${s}`).onClick(() => { row[sc] = s; this.scheduleSave(); this.renderViewPreservingScroll(); }));
|
|
});
|
|
}
|
|
}
|
|
menu.addSeparator();
|
|
const highlighted = this.isHighlighted(row);
|
|
menu.addItem(i => i.setTitle(highlighted ? "Remove highlight" : "Highlight").setIcon("highlighter")
|
|
.onClick(() => { this.toggleHighlight(row); this.renderViewPreservingScroll(); }));
|
|
menu.addSeparator();
|
|
menu.addItem(i => i.setTitle("Delete").setIcon("trash").onClick(() => this.deleteWithUndo(row)));
|
|
menu.showAtMouseEvent(e);
|
|
}
|
|
|
|
async openOrCreateNotes(row: CSVRow): Promise<void> {
|
|
const path = this.notesFilePath(row);
|
|
let file = this.app.vault.getAbstractFileByPath(path) as TFile|null;
|
|
if (!file) {
|
|
// Serialize properties with stringifyYaml — hand-built `key: "value"`
|
|
// lines broke on values containing backslashes (invalid YAML escapes)
|
|
// or newlines (multi-line CSV cells), which corrupted the whole
|
|
// properties block in Obsidian.
|
|
const fmObj: Record<string, unknown> = {};
|
|
// On a tasks/projects file, surface the project column as a tag so notes
|
|
// spawned from rows roll up under #project-… in the tag pane / graph /
|
|
// any vault-wide dashboard scan — not just as a structured property.
|
|
// Comma-separated projects → one tag each. Skipped for non-task files.
|
|
const projectCol = hasTaskColumns(this) ? taskProjectCol(this) : null;
|
|
const tags = projectCol
|
|
? (row[projectCol] ?? "").split(",").map(p=>tagify(p.trim())).filter(Boolean).map(t=>`project-${t}`)
|
|
: [];
|
|
if (tags.length) fmObj.tags = tags;
|
|
for (const h of this.headers) { if (!this.isNotesCol(h) && row[h]) fmObj[h] = row[h]; }
|
|
// Wikilink back to the CSV: gives every sidecar note a backlink to its
|
|
// source file. Obsidian property names are case-insensitive, so skip
|
|
// when any column is already named "source".
|
|
if (this.file && !Object.keys(fmObj).some(k=>k.toLowerCase()==="source")) fmObj.source = `[[${this.file.path}]]`;
|
|
const fm = ["---",stringifyYaml(fmObj).trimEnd(),"---","",`# ${this.getTitle(row)}`,"",""].join("\n");
|
|
const notesCol = this.headers.find(h=>this.isNotesCol(h));
|
|
const content = fm+(notesCol&&row[notesCol]?.trim()?row[notesCol]:"");
|
|
const folderPath = path.substring(0,path.lastIndexOf("/"));
|
|
if (folderPath&&!this.app.vault.getAbstractFileByPath(folderPath)) await this.app.vault.createFolder(folderPath);
|
|
file = await this.app.vault.create(path,content);
|
|
new Notice(`Created: ${file.name}`);
|
|
}
|
|
await this.app.workspace.getLeaf("tab").openFile(file as TFile);
|
|
}
|
|
|
|
openNoteExpander(row: CSVRow, notesCol: string): void {
|
|
new NoteExpanderModal(
|
|
this.app,
|
|
row,
|
|
notesCol,
|
|
this.headers,
|
|
this.file?.path ?? "",
|
|
this.isNotesCol.bind(this),
|
|
this.isSelectCol.bind(this),
|
|
this.getColumnValues.bind(this),
|
|
(updatedRow) => {
|
|
// Apply all field changes back to the original row
|
|
Object.assign(row, updatedRow);
|
|
this.scheduleSave();
|
|
// Re-render rebuilds the whole content area, so scroll positions
|
|
// would reset to (0,0). Snapshot scroll across all known scrollers
|
|
// (table y+x, library y, kanban board x + per-column y) and restore
|
|
// after the re-render so closing the expander doesn't jump the view.
|
|
this.renderViewPreservingScroll();
|
|
},
|
|
// Delete with undo: the helper handles splice + save + rerender + Notice.
|
|
() => this.deleteWithUndo(row),
|
|
c => this.isCategoricalCol(c),
|
|
this.titleKey(),
|
|
c => this.getBooleanColumns().includes(c),
|
|
c => this.isDateCol(c)
|
|
).open();
|
|
}
|
|
|
|
/**
|
|
* Snapshot scroll positions of every scrollable container in the current
|
|
* view, re-render, then put them back. Used after modal saves so the user
|
|
* isn't yanked back to (0,0) every time they edit an entry.
|
|
*
|
|
* Kanban columns are keyed by their header text — column DOM nodes are
|
|
* recreated on re-render, so we re-find the same column by its (stable)
|
|
* title and replay its scrollTop. Replays are scheduled across two rAFs +
|
|
* a setTimeout because Obsidian sometimes adjusts scroll after our
|
|
* immediate write (matches the same defense used in the inline note editor).
|
|
*/
|
|
renderViewPreservingScroll(): void {
|
|
const root = this.contentEl;
|
|
const contentArea = root.querySelector<HTMLElement>(".csv-content-area");
|
|
const tableWrap = root.querySelector<HTMLElement>(".csv-table-wrapper");
|
|
const kanbanBoard = root.querySelector<HTMLElement>(".csv-kanban-board");
|
|
const snapshot = {
|
|
contentTop: contentArea?.scrollTop ?? 0,
|
|
contentLeft: contentArea?.scrollLeft ?? 0,
|
|
tableLeft: tableWrap?.scrollLeft ?? 0,
|
|
tableTop: tableWrap?.scrollTop ?? 0,
|
|
boardLeft: kanbanBoard?.scrollLeft ?? 0,
|
|
cols: new Map<string, number>(),
|
|
};
|
|
root.querySelectorAll<HTMLElement>(".csv-kanban-col").forEach(col => {
|
|
const title = col.querySelector(".csv-kanban-col-title")?.textContent ?? "";
|
|
const body = col.querySelector<HTMLElement>(".csv-kanban-col-body");
|
|
if (title && body) snapshot.cols.set(title, body.scrollTop);
|
|
});
|
|
|
|
this.renderView();
|
|
|
|
const restore = () => {
|
|
const ca = this.contentEl.querySelector<HTMLElement>(".csv-content-area");
|
|
const tw = this.contentEl.querySelector<HTMLElement>(".csv-table-wrapper");
|
|
const kb = this.contentEl.querySelector<HTMLElement>(".csv-kanban-board");
|
|
if (ca) { ca.scrollTop = snapshot.contentTop; ca.scrollLeft = snapshot.contentLeft; }
|
|
if (tw) { tw.scrollLeft = snapshot.tableLeft; tw.scrollTop = snapshot.tableTop; }
|
|
if (kb) kb.scrollLeft = snapshot.boardLeft;
|
|
this.contentEl.querySelectorAll<HTMLElement>(".csv-kanban-col").forEach(col => {
|
|
const title = col.querySelector(".csv-kanban-col-title")?.textContent ?? "";
|
|
const body = col.querySelector<HTMLElement>(".csv-kanban-col-body");
|
|
const saved = snapshot.cols.get(title);
|
|
if (body && saved != null) body.scrollTop = saved;
|
|
});
|
|
};
|
|
restore();
|
|
requestAnimationFrame(() => {
|
|
restore();
|
|
requestAnimationFrame(restore);
|
|
});
|
|
setTimeout(restore, 50);
|
|
}
|
|
|
|
openAddModal(): void {
|
|
// On a tasks/projects file, seed the Type and Priority dropdowns with their
|
|
// canonical vocabularies so a brand-new file offers them before any row
|
|
// exists to harvest values from. (Skipped elsewhere — a movies "Type"
|
|
// holds genres, not Task/Note/Idea.)
|
|
const optionPresets: Record<string, string[]> = {};
|
|
if (hasTaskColumns(this)) {
|
|
const typeCol = taskTypeCol(this);
|
|
if (typeCol) optionPresets[typeCol] = ["Task", "Note", "Idea"];
|
|
const priCol = taskPriorityCol(this);
|
|
if (priCol) optionPresets[priCol] = ["Low", "Medium", "High"];
|
|
}
|
|
new AddEntryModal(
|
|
this.app,
|
|
this.headers,
|
|
this.isNotesCol.bind(this),
|
|
this.isSelectCol.bind(this),
|
|
this.getColumnValues.bind(this),
|
|
(row) => {
|
|
this.rows.push(row);
|
|
this.scheduleSave();
|
|
// Keep the user where they were — the new entry's discoverable via
|
|
// the Notice; yanking to (0,0) just disorients them.
|
|
this.renderViewPreservingScroll();
|
|
new Notice(`Added: ${this.getTitle(row)}`);
|
|
},
|
|
optionPresets,
|
|
// Habit/0-1 columns render as toggles in the add form.
|
|
(h) => this.getBooleanColumns().includes(h),
|
|
c => this.isCategoricalCol(c),
|
|
this.titleKey(),
|
|
c => this.isDateCol(c)
|
|
).open();
|
|
}
|
|
|
|
// ── Select field ───────────────────────────────────────────────────────────
|
|
|
|
renderSelectField(container: HTMLElement, row: CSVRow, h: string): HTMLElement {
|
|
const val = row[h] ?? "";
|
|
const chip = container.createDiv({ cls: `csv-select-chip ${val ? "" : "empty"}` });
|
|
chip.setText(val || "—");
|
|
if (h.toLowerCase() === "status" && val) chip.addClass(`status-chip-${val.toLowerCase().replace(/\s+/g,"-")}`);
|
|
chip.addEventListener("click", e => {
|
|
e.stopPropagation();
|
|
showSelectPicker(chip, row[h] ?? "", this.getColumnValues(h), (newVal) => {
|
|
row[h] = newVal;
|
|
chip.setText(newVal || "—");
|
|
chip.className = `csv-select-chip ${newVal ? "" : "empty"}`;
|
|
if (h.toLowerCase() === "status" && newVal) chip.addClass(`status-chip-${newVal.toLowerCase().replace(/\s+/g,"-")}`);
|
|
this.scheduleSave();
|
|
}, this.contentEl, { multi: isMultiValueColName(h) });
|
|
});
|
|
return chip;
|
|
}
|
|
|
|
// ── Render root ────────────────────────────────────────────────────────────
|
|
|
|
private contentArea: HTMLElement | null = null;
|
|
|
|
renderView(contentOnly = false): void {
|
|
const root = this.contentEl;
|
|
|
|
if (!contentOnly) {
|
|
root.empty(); root.addClass("datadeck-root");
|
|
this.renderComponent.unload();
|
|
this.renderComponent = new Component(); this.renderComponent.load();
|
|
renderToolbar(this, root);
|
|
this.contentArea = root.createDiv({ cls: "csv-content-area" });
|
|
this.installTouchScrollGuard(this.contentArea);
|
|
} else if (this.contentArea) {
|
|
this.contentArea.empty();
|
|
}
|
|
|
|
const content = this.contentArea;
|
|
if (!content) return;
|
|
// Kanban manages its own scroll: the board fills the viewport, each
|
|
// column scrolls internally. Without this flag .csv-content-area also
|
|
// y-scrolls and the user gets two stacked scrollbars for the same content.
|
|
// Table mode also opts out: the table wrapper becomes the y-scroller so
|
|
// the sticky header has a scrollport to stick to (sticky inside an
|
|
// overflow-x:auto wrapper that never y-scrolls is a no-op).
|
|
content.toggleClass("csv-content-area--no-yscroll", this.mode === "kanban-genre" || this.mode === "table");
|
|
|
|
if (!this.headers.length) {
|
|
// Distinguish "truly malformed" from "brand-new empty file": both end
|
|
// up here, but most of the time it's the latter (user just created
|
|
// 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." });
|
|
return;
|
|
}
|
|
if (this.rows.length === 0) {
|
|
// Headers present but no data — invite the user to add the first row.
|
|
// Skip all per-mode renders; they'd produce empty grids that read as
|
|
// "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" });
|
|
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;
|
|
}
|
|
// renderDashboard is async (lazy-loads Chart.js); no one awaits renderView,
|
|
// so the fire-and-forget here is intentional — dashboard chrome paints
|
|
// synchronously, the chart lands a tick later.
|
|
if (this.mode === "travel") void renderTravel(content, this.rows, () => this.loadMapSvg(), () => this.scheduleSave(),
|
|
this.settings.showResidency === false ? null : (this.settings.residencyRules ?? null),
|
|
(teardown) => this.renderComponent.register(teardown));
|
|
else if (this.mode === "dashboard") void renderDashboard(this, content);
|
|
else if (this.mode === "library") renderLibrary(this, content);
|
|
else if (this.mode === "kanban-genre") renderKanbanGenre(this, content);
|
|
else if (this.mode === "stats") renderStats(this, content);
|
|
else if (this.mode === "chart") void renderChart(this, content);
|
|
else if (this.mode === "focus") renderFocus(this, content);
|
|
else if (this.mode === "tasks") renderTasks(this, content);
|
|
else renderTable(this, content);
|
|
}
|
|
|
|
/**
|
|
* Stop a swipe from being read as a tap on touch devices.
|
|
*
|
|
* iOS Safari is mostly good at distinguishing the two natively, but with
|
|
* CSS scroll-snap between kanban columns + momentum scrolling inside a
|
|
* column, a touch that lands briefly on a card mid-scroll can still fire
|
|
* `click` (which opens the inline note editor / the expander modal /
|
|
* etc., and the user thinks "why did I just select that?").
|
|
*
|
|
* Strategy: track touchstart→touchmove distance. If the finger moved
|
|
* more than 10 px (iOS's own tap threshold) before touchend, swallow
|
|
* the subsequent click in capture phase before any descendant handler
|
|
* sees it. Programmatic scrolls (the scroll-restore after closeInlineEditor)
|
|
* don't fire touchmove, so they can't create false lockouts.
|
|
*
|
|
* Installed once per content-area creation; persists across mode changes
|
|
* (renderView only recreates the content area when `contentOnly=false`).
|
|
*/
|
|
private installTouchScrollGuard(el: HTMLElement): void {
|
|
if (!matchMedia("(pointer: coarse)").matches) return;
|
|
let startX = 0, startY = 0, moved = false;
|
|
el.addEventListener("touchstart", (e) => {
|
|
if (e.touches.length !== 1) return;
|
|
startX = e.touches[0].clientX;
|
|
startY = e.touches[0].clientY;
|
|
moved = false;
|
|
}, { capture: true, passive: true });
|
|
el.addEventListener("touchmove", (e) => {
|
|
if (e.touches.length !== 1 || moved) return;
|
|
const dx = Math.abs(e.touches[0].clientX - startX);
|
|
const dy = Math.abs(e.touches[0].clientY - startY);
|
|
if (dx > 10 || dy > 10) moved = true;
|
|
}, { capture: true, passive: true });
|
|
el.addEventListener("click", (e) => {
|
|
if (!moved) return;
|
|
e.preventDefault();
|
|
e.stopImmediatePropagation();
|
|
}, true);
|
|
}
|
|
|
|
|
|
// ── Archive backup ──────────────────────────────────────────────────────────
|
|
|
|
async backupToArchive(): Promise<void> {
|
|
if (!this.file) return;
|
|
const folder = this.file.parent?.path ?? "";
|
|
const archiveFolder = folder ? `${folder}/Archive` : "Archive";
|
|
if (!await this.app.vault.adapter.exists(archiveFolder)) {
|
|
await this.app.vault.adapter.mkdir(archiveFolder);
|
|
}
|
|
const date = this.formatDate(new Date()); // local YYYY-MM-DD
|
|
const dest = `${archiveFolder}/${this.file.basename}_${date}.${this.file.extension}`;
|
|
if (await this.app.vault.adapter.exists(dest)) {
|
|
new Notice(`Backup already exists for today: ${dest}`);
|
|
return;
|
|
}
|
|
const buf = await this.app.vault.readBinary(this.file);
|
|
await this.app.vault.adapter.writeBinary(dest, buf);
|
|
new Notice(`Backed up to ${dest}`);
|
|
}
|
|
|
|
// ── Date detection ──────────────────────────────────────────────────────────
|
|
|
|
hasDateColumn(): boolean {
|
|
const dateCol = this.getDateCol();
|
|
return dateCol !== null;
|
|
}
|
|
|
|
// ── Travel-log detection + map asset ──────────────────────────────────────────
|
|
|
|
/**
|
|
* A file is a travel log if it carries the flat-CSV columns emitted by the
|
|
* travel-tracker's travel.py: country (ISO-2) + a date range + a `source`
|
|
* discriminator. Specific enough not to fire on movies/books/habits.
|
|
*/
|
|
isTravelFile(): boolean {
|
|
const have = new Set(this.headers.map(h => h.toLowerCase()));
|
|
return have.has("country") && have.has("date_entered")
|
|
&& have.has("date_left") && have.has("source");
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
// ── Search filtering ─────────────────────────────────────────────────────────
|
|
|
|
getFilteredRows(): CSVRow[] {
|
|
let result = this.rows;
|
|
|
|
// Filter by search query
|
|
if (this.searchQuery.trim()) {
|
|
const query = this.searchQuery.toLowerCase().trim();
|
|
result = result.filter(row => {
|
|
return this.headers.some(h => {
|
|
const val = row[h] ?? "";
|
|
return val.toLowerCase().includes(query);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Manual header-click sort takes precedence over the date default.
|
|
if (this.mode === "table" && this.tableSortCol && this.headers.includes(this.tableSortCol)) {
|
|
return sortRowsByColumn(result, this.tableSortCol, this.tableSortDir);
|
|
}
|
|
|
|
// Sort by date if in table view and has date column
|
|
const dateCol = this.getDateCol();
|
|
if (this.mode === "table" && dateCol) {
|
|
const sortNewest = this.fileCfg.sortNewestFirst ?? true;
|
|
result = [...result].sort((a, b) => {
|
|
const dateA = a[dateCol] ?? "";
|
|
const dateB = b[dateCol] ?? "";
|
|
const cmp = dateA.localeCompare(dateB);
|
|
return sortNewest ? -cmp : cmp;
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
getDateCol(): string | null {
|
|
// An explicit per-file Date pick (⚙ Config, Type = Date) wins — the
|
|
// inline csv-view host already honoured it; the full view ignored it,
|
|
// so a configured date column off in position 2+ never drove the
|
|
// dashboard/date sort here. Deliberately NOT falling back to name-based
|
|
// isDateCol like the inline host does: "Due" is date-named, and that
|
|
// fallback would auto-default every tasks file to Dashboard mode.
|
|
if (this.fileCfg.dateColumns && this.fileCfg.dateColumns.length > 0) {
|
|
const h = this.fileCfg.dateColumns[0];
|
|
return this.headers.find(header => header === h) ?? null;
|
|
}
|
|
// Check first column - if it looks like dates, use it
|
|
if (this.headers.length === 0) return null;
|
|
const firstCol = this.headers[0];
|
|
const firstColLower = firstCol.toLowerCase();
|
|
|
|
// Check by column name
|
|
if (["date", "day", "datum"].includes(firstColLower)) return firstCol;
|
|
|
|
// Check if first few values look like dates (YYYY-MM-DD or similar)
|
|
const sampleRows = this.rows.slice(0, 5);
|
|
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
|
|
const looksLikeDates = sampleRows.length > 0 &&
|
|
sampleRows.every(r => datePattern.test(r[firstCol] ?? ""));
|
|
|
|
if (looksLikeDates) return firstCol;
|
|
return null;
|
|
}
|
|
|
|
getBooleanColumns(): string[] {
|
|
// Explicit list wins outright once set, even if empty — same semantics
|
|
// as isCategoricalCol's categoricalColumns check. Previously this used
|
|
// `.length > 0`, which meant explicitly unchecking every Checkbox column
|
|
// in ⚙ Config (leaving the list empty) silently fell back to
|
|
// auto-detection instead of actually turning the habit tracker off.
|
|
if (this.fileCfg.habitColumns) {
|
|
return this.fileCfg.habitColumns.filter(h => this.headers.includes(h));
|
|
}
|
|
return this.autoDetectBooleanColumns();
|
|
}
|
|
|
|
/**
|
|
* ⚙ Config's "Clean up" action for a Checkbox-typed column: rewrites every
|
|
* row's raw value in `header` to exactly "1" (isTruthy match) or "0"
|
|
* (everything else) — the same rule the toggle itself already uses to
|
|
* decide checked/unchecked, applied to the whole column at once instead of
|
|
* one row at a time as each happens to get touched. Acts on the real CSV
|
|
* data immediately (like addColumn/removeColumn), not deferred to Save.
|
|
* Returns how many cells actually changed, for the confirmation Notice.
|
|
*/
|
|
cleanupBooleanColumn(header: string): number {
|
|
let changed = 0;
|
|
this.rows.forEach(row => {
|
|
const normalized = this.isTruthy(row[header] ?? "") ? "1" : "0";
|
|
if (row[header] !== normalized) changed++;
|
|
row[header] = normalized;
|
|
});
|
|
if (changed > 0) {
|
|
this.scheduleSave();
|
|
this.renderViewPreservingScroll();
|
|
}
|
|
return changed;
|
|
}
|
|
|
|
autoDetectBooleanColumns(): string[] {
|
|
// Detect columns that look like boolean/habit columns (values are 0/1, true/false, yes/no, or empty).
|
|
return this.headers.filter(h => {
|
|
if (h === this.getDateCol() || this.isNotesCol(h)) return false;
|
|
const values = this.rows.map(r => (r[h] ?? "").toLowerCase().trim());
|
|
return looksBoolean(values);
|
|
});
|
|
}
|
|
|
|
parseDate(dateStr: string): Date | null {
|
|
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
if (!match) return null;
|
|
return new Date(parseInt(match[1]), parseInt(match[2]) - 1, parseInt(match[3]));
|
|
}
|
|
|
|
formatDate(date: Date): string {
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
const d = String(date.getDate()).padStart(2, "0");
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
isTruthy(val: string): boolean {
|
|
return isTruthyVal(val);
|
|
}
|
|
|
|
// ── Dashboard view ──────────────────────────────────────────────────────────
|
|
|
|
selectedDate: string | null = null;
|
|
selectedHabit: string | null = null;
|
|
chartInstance: ChartType | null = null;
|
|
|
|
|
|
timelineYear: number = new Date().getFullYear();
|
|
|
|
|
|
|
|
|
|
// ── Library View ────────────────────────────────────────────────────────────
|
|
|
|
libraryStatusFilter: string = "all";
|
|
libraryGenreFilter: string = "all";
|
|
// Group values (lowercased) collapsed by default in Cards view. Only the
|
|
// inline csv-view block populates this (via its `collapse:` directive); the
|
|
// full-page view leaves it empty, so every section opens as before.
|
|
collapsedGroups: Set<string> = new Set();
|
|
|
|
// ── Tasks view ────────────────────────────────────────────────────────────────
|
|
|
|
taskProjectFilter: string = "all";
|
|
taskTypeFilter: string = "all";
|
|
|
|
// ── Focus view ───────────────────────────────────────────────────────────────
|
|
|
|
focusIndex: number = 0;
|
|
// Set by the focus renderer before a nav-triggered re-render so the rebuilt
|
|
// card can reclaim keyboard focus — but only then, so a search-driven
|
|
// re-render never steals focus from the toolbar input.
|
|
focusNavPending: boolean = false;
|
|
|
|
/** Render markdown text into an element, tied to this view's lifecycle. */
|
|
renderMarkdownInto(el: HTMLElement, text: string): void {
|
|
void MarkdownRenderer.render(this.app, text, el, this.file?.path ?? "", this.renderComponent);
|
|
}
|
|
|
|
// ── Table view ───────────────────────────────────────────────────────────────
|
|
|
|
// Click-to-sort state (session-only; the persisted per-file default stays
|
|
// the date-column newest/oldest toggle in fileCfg.sortNewestFirst).
|
|
tableSortCol: string | null = null;
|
|
tableSortDir: "asc" | "desc" = "asc";
|
|
|
|
|
|
/** Switch to the next valid view mode for this file (palette command). */
|
|
cycleMode(): void {
|
|
const modes = availableModes(this);
|
|
if (modes.length < 2) return;
|
|
const idx = modes.findIndex(m => m.id === this.mode);
|
|
this.mode = modes[(idx + 1) % modes.length].id;
|
|
this.renderView();
|
|
}
|
|
|
|
onunload(): void { this.renderComponent.unload(); if(this.saveTimer) window.clearTimeout(this.saveTimer); }
|
|
}
|
|
|
|
// ─── File templates ─────────────────────────────────────────────────────────
|
|
//
|
|
// Canonical column sets for the "create … file" palette commands. `headers`
|
|
// must satisfy the target view's detector (see the command registration in
|
|
// onload). `defaultName` seeds the name prompt; `mode` is pinned in
|
|
// fileConfigs so the new file opens straight into the right renderer.
|
|
interface FileTemplate {
|
|
id: string;
|
|
command: string;
|
|
defaultName: string;
|
|
headers: string[];
|
|
mode: ViewMode;
|
|
configOverrides?: Partial<FileConfig>;
|
|
}
|
|
|
|
const FILE_TEMPLATES: FileTemplate[] = [
|
|
{
|
|
id: "tasks",
|
|
command: "Create tasks file",
|
|
defaultName: "Tasks",
|
|
// Type + Project are the two columns the tasks view is actually designed
|
|
// around (per-type sections, grouped by project) — without Project the
|
|
// group falls back to whatever pickFallbackGroupCol guesses (Priority),
|
|
// which read as nonsense headings. Status stays a *word* column
|
|
// (done/…), NOT a Checkbox: the view's done-toggle writes DONE_WORDS
|
|
// vocabulary, and typing it Checkbox made the Add-form toggle write
|
|
// "1"/"0" that the view then couldn't agree with.
|
|
headers: ["Title", "Type", "Project", "Priority", "Due", "Status", "Notes"],
|
|
mode: "tasks",
|
|
configOverrides: {
|
|
categoricalColumns: ["Type", "Priority"],
|
|
dateColumns: ["Due"],
|
|
statusColumn: "Status",
|
|
notesColumn: "Notes"
|
|
}
|
|
},
|
|
{
|
|
id: "travel",
|
|
command: "Create travel file",
|
|
defaultName: "Travel",
|
|
// Exactly travel.py's flat-CSV columns, lowercase — analyzeTravel reads
|
|
// literal keys (r.country, r.notes, …), so the old capitalized
|
|
// Country/Notes headers passed detection (isTravelFile lowercases) but
|
|
// produced a travel view where every row was silently dropped.
|
|
headers: ["date_entered", "date_left", "country", "city", "visa_status", "notes", "source", "resolved"],
|
|
mode: "travel",
|
|
},
|
|
{
|
|
id: "habits",
|
|
command: "Create habit tracker file",
|
|
defaultName: "Habits",
|
|
headers: ["Date", "Exercise", "Meditate", "Read", "Notes"],
|
|
mode: "dashboard",
|
|
configOverrides: {
|
|
// Pinned — auto-detection needs at least one row of values, so a fresh
|
|
// file otherwise renders a dashboard with zero habit toggles.
|
|
habitColumns: ["Exercise", "Meditate", "Read"],
|
|
notesColumn: "Notes",
|
|
// "Read" is a status-column alias; explicitly no status column here so
|
|
// Stats/Library don't misread a habit as the file's status.
|
|
statusColumn: "",
|
|
}
|
|
},
|
|
{
|
|
id: "chart",
|
|
command: "Create chart file",
|
|
defaultName: "Measurements",
|
|
// A dated measurement log — the most common personal chart shape
|
|
// (weight, km, hours…). Opens as a table until two rows exist
|
|
// (hasChartColumns needs data to plot), then lands in Chart with a
|
|
// date X-axis, Value on Y, and the fit line on.
|
|
headers: ["Date", "Value", "Notes"],
|
|
mode: "chart",
|
|
configOverrides: {
|
|
chartYCol: "Value",
|
|
chartFit: "linear",
|
|
notesColumn: "Notes",
|
|
}
|
|
},
|
|
];
|
|
|
|
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
|
|
|
export default class CardViewPlugin extends Plugin {
|
|
settings: CardViewSettings = DEFAULT_SETTINGS;
|
|
async onload(): Promise<void> {
|
|
await this.loadSettings();
|
|
this.registerView(CARD_VIEW_TYPE, leaf=>new CardView(leaf, this.settings, () => this.saveSettings()));
|
|
this.registerExtensions(["csv"], CARD_VIEW_TYPE);
|
|
this.addSettingTab(new CardViewSettingTab(this.app, this));
|
|
|
|
// Register csv-add code block for mobile entry
|
|
this.registerMarkdownCodeBlockProcessor("csv-add", async (source, el, ctx) => {
|
|
await renderAddEntryForm(this.app, source.trim(), el, ctx, this.settings.fileConfigs);
|
|
});
|
|
|
|
// csv-random: a random entry from a CSV — quote/word of the day for
|
|
// daily-note templates. Re-rolls per render + via its ↻ button.
|
|
this.registerMarkdownCodeBlockProcessor("csv-random", async (source, el, ctx) => {
|
|
await renderRandomCard(this.app, source.trim(), el, ctx);
|
|
});
|
|
|
|
// csv-view: an inline, editable table/cards/kanban view of a CSV file —
|
|
// a .base / Notion-DB-style embed. Reuses the full-view renderers via a
|
|
// lightweight host tied to the block's lifecycle. See src/inline-view.ts.
|
|
registerCsvViewBlock(
|
|
this.app,
|
|
this.settings,
|
|
() => this.saveSettings(),
|
|
(lang, handler) => this.registerMarkdownCodeBlockProcessor(lang, handler),
|
|
);
|
|
|
|
// csv-chart: an inline read-only chart of a CSV (or a pure y = f(x)
|
|
// formula plot) — scatter/line, best-fit, formula overlay. See
|
|
// src/chart-block.ts.
|
|
registerCsvChartBlock(
|
|
this.app,
|
|
(lang, handler) => this.registerMarkdownCodeBlockProcessor(lang, handler),
|
|
);
|
|
|
|
// csv-tasks: a cross-file tasks board — merge task rows from a folder
|
|
// and/or file list into one editable board. The phase-2 replacement for
|
|
// the old vault-wide DataviewJS dashboard. See src/tasks-block.ts.
|
|
registerCsvTasksBlock(
|
|
this.app,
|
|
this.settings,
|
|
(lang, handler) => this.registerMarkdownCodeBlockProcessor(lang, handler),
|
|
);
|
|
|
|
// Palette commands — operate on the active CSV view, so they're
|
|
// hotkey-able (Settings → Hotkeys → filter "CSV Card View").
|
|
this.addCommand({
|
|
id: "add-entry",
|
|
name: "Add entry to current CSV",
|
|
checkCallback: (checking) => {
|
|
const view = this.app.workspace.getActiveViewOfType(CardView);
|
|
if (!view) return false;
|
|
if (!checking) view.openAddModal();
|
|
return true;
|
|
},
|
|
});
|
|
this.addCommand({
|
|
id: "cycle-view-mode",
|
|
name: "Cycle view mode",
|
|
checkCallback: (checking) => {
|
|
const view = this.app.workspace.getActiveViewOfType(CardView);
|
|
if (!view) return false;
|
|
if (!checking) view.cycleMode();
|
|
return true;
|
|
},
|
|
});
|
|
|
|
// "Create … file" palette commands — one per view template. Each scaffolds
|
|
// a fresh CSV with the canonical columns for that view, pins its
|
|
// defaultMode so it opens straight into the right renderer, and opens it.
|
|
// Always available (no active-view gate), so they're the entry point to a
|
|
// new tracker from anywhere. Headers must satisfy each view's detector:
|
|
// - tasks: hasTaskColumns() (Priority/Due present)
|
|
// - travel: isTravelFile() (country + date_entered + date_left + source)
|
|
// - dashboard: a date column + boolean habit columns.
|
|
for (const tpl of FILE_TEMPLATES) {
|
|
this.addCommand({
|
|
id: `create-${tpl.id}`,
|
|
name: tpl.command,
|
|
callback: () => this.createTemplateFile(tpl),
|
|
});
|
|
}
|
|
|
|
// Migrate per-file config keys when the user renames or moves a
|
|
// tracked csv inside Obsidian. Without this, `fileConfigs[oldPath]`
|
|
// (cardFields, categoryColumn, defaultMode, etc.) is orphaned and the
|
|
// file silently reverts to auto-detected defaults.
|
|
this.registerEvent(this.app.vault.on("rename", async (file, oldPath) => {
|
|
if (!(file instanceof TFile)) return;
|
|
if (file.extension !== "csv") return;
|
|
if (!this.settings.fileConfigs[oldPath]) return;
|
|
migrateFileConfigKey(this.settings.fileConfigs, oldPath, file.path);
|
|
await this.saveSettings();
|
|
}));
|
|
|
|
// Same for delete — drop the orphaned entry so data.json doesn't grow forever.
|
|
this.registerEvent(this.app.vault.on("delete", async (file) => {
|
|
if (!(file instanceof TFile)) return;
|
|
if (!this.settings.fileConfigs[file.path]) return;
|
|
delete this.settings.fileConfigs[file.path];
|
|
await this.saveSettings();
|
|
}));
|
|
|
|
// 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"
|
|
});
|
|
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" } });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
|
|
/**
|
|
* Scaffold a new CSV from a view template: prompt for a name, create it
|
|
* (header row only) next to the active file or at the vault root, pin its
|
|
* defaultMode so it opens in the right renderer, and open it. Resolves name
|
|
* collisions by appending " 2", " 3", … rather than overwriting.
|
|
*/
|
|
async createTemplateFile(tpl: FileTemplate): Promise<void> {
|
|
new PromptModal(
|
|
this.app,
|
|
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}`);
|
|
}
|
|
).open();
|
|
}
|
|
|
|
async loadSettings(): Promise<void> {
|
|
this.settings=Object.assign({},DEFAULT_SETTINGS,await this.loadData());
|
|
// 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 ?? []));
|
|
}
|
|
async saveSettings(): Promise<void> { await this.saveData(this.settings); }
|
|
}
|