mirror of
https://github.com/svm0n/datadeck.git
synced 2026-07-22 08:31:46 +00:00
Inline view (src/inline-view.ts): a csv-view code block renders an editable table/cards/kanban view of a CSV inside any note. InlineCardHost satisfies the renderers' duck-typed surface so renderTable/renderLibrary/renderKanbanGenre are reused unchanged; edits write back via vault.modify and re-sync off the modify event. Block directives: file/mode/height/collapse. - Cards/Kanban thumbnails: getImageCol() auto-detects Image/Cover/Poster/… columns; resolveImageSrc() handles vault paths, wikilinks, md-images, URLs. - Card title + context-menu "Open entry" now open the expander even without a notes column (NoteExpanderModal hides its notes section when absent), so notes-less files (e.g. applications trackers) are editable from cards. - collapse: directive + fileCfg.collapsedGroups: Cards groups collapse by default and remember manual toggles (full-page too). - Habit add-form toggles for 0/1 columns; pseudo-categorical columns (few distinct values) get option dropdowns in the add + edit forms via shared looksCategorical() helper. - Kanban: empty group value → "—" column (was dropped in default grouping); rows with blank/unknown status render ungrouped instead of vanishing while the column header still counted them. - Dark-mode table row hover: explicit text-tint overlay (was near-invisible). - Docs: csv-view syntax in README; architecture + open follow-ups in handoff. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
982 lines
43 KiB
TypeScript
982 lines
43 KiB
TypeScript
import {
|
|
App,
|
|
Plugin,
|
|
FileView,
|
|
WorkspaceLeaf,
|
|
Component,
|
|
MarkdownRenderer,
|
|
Menu,
|
|
Notice,
|
|
TFile,
|
|
normalizePath,
|
|
} 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 } from "./src/utils";
|
|
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 { renderFocus } from "./src/view/focus";
|
|
import { renderTasks, hasTaskColumns, taskProjectCol, taskTypeCol, taskPriorityCol } from "./src/view/tasks";
|
|
import { registerCsvViewBlock } from "./src/inline-view";
|
|
|
|
// 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 iPhone where iCloud 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 ───────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
} catch (e) {
|
|
console.error("CardView load error", e);
|
|
this.headers = []; this.rows = [];
|
|
// 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 === "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 });
|
|
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();
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
getNotesCol(): string | null {
|
|
// 1. Per-file override
|
|
if (this.fileCfg.notesColumn) return this.fileCfg.notesColumn;
|
|
// 2. Fallback chain
|
|
return this.resolveCol([
|
|
"Notes","notes","Note","note",
|
|
"Summary","summary",
|
|
"Review","review",
|
|
"Quote","quote","Quotes","quotes",
|
|
"Comment","comment","Comments","comments",
|
|
"Description","description",
|
|
"Annotation","annotation",
|
|
]);
|
|
}
|
|
|
|
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()); }
|
|
|
|
getStatusCol(): string | null {
|
|
if (this.fileCfg.statusColumn) {
|
|
return this.headers.find(h => h.toLowerCase() === this.fileCfg.statusColumn!.toLowerCase()) ?? null;
|
|
}
|
|
return this.resolveCol([
|
|
"Status","status",
|
|
"State","state",
|
|
"Progress","progress",
|
|
"Stage","stage",
|
|
"Read","read",
|
|
"Watched","watched","Seen","seen",
|
|
"Done","done","Completed","completed",
|
|
]);
|
|
}
|
|
|
|
getCategoryCol(): string | null {
|
|
if (this.fileCfg.categoryColumn) {
|
|
return this.headers.find(h => h.toLowerCase() === this.fileCfg.categoryColumn!.toLowerCase()) ?? null;
|
|
}
|
|
return this.resolveCol([
|
|
"Category","category",
|
|
"Categories","categories",
|
|
"Genre","genre","Genres","genres",
|
|
"Type","type","Types","types",
|
|
"Tag","tag","Tags","tags",
|
|
"Topic","topic","Topics","topics",
|
|
"Subject","subject",
|
|
"Section","section",
|
|
]);
|
|
}
|
|
|
|
titleKey(): string | undefined {
|
|
return this.resolveCol(["Title","title","Name","name"]) ?? 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) 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();
|
|
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) {
|
|
const props = this.headers.filter(h=>!this.isNotesCol(h)&&row[h]).map(h=>`${h}: "${row[h].replace(/"/g,'\\"')}"`);
|
|
// 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) props.unshift(`tags: [${tags.join(", ")}]`);
|
|
const fm = ["---",...props,"---","",`# ${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)
|
|
).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)
|
|
).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("csv-card-view-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 === "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 = new Date().toISOString().slice(0, 10); // 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 — see handoff. 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/csv-card-view/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 {
|
|
// 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[] {
|
|
// Use configured habit columns if set, otherwise auto-detect
|
|
if (this.fileCfg.habitColumns && this.fileCfg.habitColumns.length > 0) {
|
|
return this.fileCfg.habitColumns.filter(h => this.headers.includes(h));
|
|
}
|
|
return this.autoDetectBooleanColumns();
|
|
}
|
|
|
|
autoDetectBooleanColumns(): string[] {
|
|
// Detect columns that look like boolean/habit columns (values are 0/1, true/false, yes/no, or empty)
|
|
const boolPatterns = ["0", "1", "true", "false", "yes", "no", ""];
|
|
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 values.every(v => boolPatterns.includes(v));
|
|
});
|
|
}
|
|
|
|
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 {
|
|
const v = (val ?? "").toLowerCase().trim();
|
|
return v === "1" || v === "true" || v === "yes";
|
|
}
|
|
|
|
// ── 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;
|
|
}
|
|
|
|
const FILE_TEMPLATES: FileTemplate[] = [
|
|
{
|
|
id: "tasks",
|
|
command: "Create tasks file",
|
|
defaultName: "Tasks",
|
|
headers: ["Title", "Type", "Project", "Status", "Priority", "Due", "Notes"],
|
|
mode: "tasks",
|
|
},
|
|
{
|
|
id: "travel",
|
|
command: "Create travel file",
|
|
defaultName: "Travel",
|
|
headers: ["Country", "date_entered", "date_left", "source", "Notes"],
|
|
mode: "travel",
|
|
},
|
|
{
|
|
id: "habits",
|
|
command: "Create habit tracker file",
|
|
defaultName: "Habits",
|
|
headers: ["Date", "Exercise", "Meditate", "Read", "Notes"],
|
|
mode: "dashboard",
|
|
},
|
|
];
|
|
|
|
// ─── 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);
|
|
});
|
|
|
|
// 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),
|
|
);
|
|
|
|
// 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.
|
|
this.settings.fileConfigs[file.path] = { ...this.settings.fileConfigs[file.path], defaultMode: tpl.mode };
|
|
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); }
|
|
}
|