feat: inline csv-view block, image columns, kanban grouping fixes

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>
This commit is contained in:
SVM0N 2026-06-28 17:53:11 +00:00
parent 5011fe2433
commit 8518dd7a2f
10 changed files with 937 additions and 106 deletions

44
main.js

File diff suppressed because one or more lines are too long

34
main.ts
View file

@ -18,7 +18,7 @@ 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 } from "./src/utils";
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";
@ -32,6 +32,7 @@ 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).
@ -238,6 +239,12 @@ export class CardView extends FileView {
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 ─────────────────────────────────────────────────────────────
@ -298,10 +305,9 @@ export class CardView extends FileView {
openRowContextMenu(row: CSVRow, e: MouseEvent): void {
const menu = new Menu();
menu.addItem(i => i.setTitle("Open / Create Notes file").setIcon("file-text").onClick(() => this.openOrCreateNotes(row)));
const notesCol = this.getNotesCol();
if (notesCol) {
menu.addItem(i => i.setTitle("Open entry").setIcon("maximize").onClick(() => this.openNoteExpander(row, notesCol)));
}
// 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);
@ -448,7 +454,9 @@ export class CardView extends FileView {
this.renderViewPreservingScroll();
new Notice(`Added: ${this.getTitle(row)}`);
},
optionPresets
optionPresets,
// Habit/0-1 columns render as toggles in the add form.
(h) => this.getBooleanColumns().includes(h)
).open();
}
@ -739,6 +747,10 @@ export class CardView extends FileView {
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 ────────────────────────────────────────────────────────────────
@ -837,6 +849,16 @@ export default class CardViewPlugin extends Plugin {
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({

View file

@ -1,7 +1,7 @@
import { App, Notice, TFile, MarkdownPostProcessorContext } from "obsidian";
import Papa from "papaparse";
import { CSVRow } from "./types";
import { parseCSV, resolvePath, titleCase } from "./utils";
import { parseCSV, resolvePath, titleCase, looksCategorical } from "./utils";
// ─── csv-add code block (mobile entry form) ──────────────────────────────────
// Extracted from CardViewPlugin. Depends only on `app` (vault/workspace),
@ -137,7 +137,7 @@ export async function renderAddEntryForm(app: App, source: string, el: HTMLEleme
otherCols.forEach(h => {
const row = makeRow(h, "field");
const uniqueVals = new Set(rows.map(r => (r[h] ?? "").trim()).filter(Boolean));
if (uniqueVals.size > 0 && uniqueVals.size <= 15) {
if (looksCategorical(uniqueVals.size)) {
const select = row.createEl("select", { cls: "csv-add-row-control" });
select.createEl("option", { text: "—", value: "" });
Array.from(uniqueVals).sort().forEach(v => select.createEl("option", { text: v, value: v }));

552
src/inline-view.ts Normal file
View file

@ -0,0 +1,552 @@
// ─── csv-view code block: an inline, editable CSV view ───────────────────────
//
// Renders a CSV file's data as a table / cards / kanban *inside a note* — the
// way a `.base` block or a Notion linked-database renders a view in a page.
// Reuses the full-view renderers (renderTable / renderLibrary / renderKanban)
// verbatim by giving them a host object that satisfies the same duck-typed
// surface CardView exposes, but lives in a markdown block instead of a leaf.
//
// ```csv-view
// file: ../movies.csv (sibling / ../ walked / vault-relative, like csv-add)
// mode: table (table | cards | kanban — default: table)
// height: 480 (optional max content height in px)
// ```
//
// Edits (inline cell edits, status chips, drag-free status changes via the
// row menu, the inline note editor, + Add, delete) write back to the source
// CSV via app.vault.modify, the same path the full view uses. Other open
// views of the same file (a .csv tab, or another csv-view block) re-sync off
// the vault `modify` event. See HANDOFF for the concurrency model.
import {
App,
TFile,
Component,
MarkdownRenderChild,
MarkdownRenderer,
MarkdownPostProcessorContext,
Menu,
Notice,
normalizePath,
} from "obsidian";
import Papa from "papaparse";
import type { CardView } from "../main";
import { CSVRow, ViewMode, FileConfig, CardViewSettings } from "./types";
import { parseCSV, resolvePath, sanitizeFilename, showSelectPicker, IMAGE_COL_ALIASES } from "./utils";
import { AddEntryModal, NoteExpanderModal } from "./modals";
import { renderTable } from "./view/table";
import { renderLibrary } from "./view/library";
import { renderKanbanGenre, effectiveGroupCol } from "./view/kanban";
/** Modes the inline view offers. A subset of the full ViewMode union. */
type InlineMode = "table" | "library" | "kanban-genre";
const MODE_LABELS: { id: InlineMode; label: string }[] = [
{ id: "table", label: "Table" },
{ id: "library", label: "Cards" },
{ id: "kanban-genre", label: "Kanban" },
];
/**
* Duck-typed TFile check mirrors random-block.ts so the block is drivable
* with a stub vault in the smoke tests (cross-bundle instanceof is unreliable).
*/
function asFile(f: unknown): TFile | null {
return f && typeof f === "object" && "basename" in (f as object) ? (f as TFile) : null;
}
interface BlockOptions {
file: string;
mode: InlineMode;
height: number | null;
collapse: string[]; // group values collapsed by default in Cards view
}
/** Parse the `key: value` lines of a csv-view block. Forgiving, like csv-random. */
function parseBlockSource(source: string): BlockOptions {
const lines = source.split("\n").map(l => l.trim()).filter(Boolean);
const opt = (key: string) =>
lines.find(l => l.toLowerCase().startsWith(key + ":"))?.slice(key.length + 1).trim() ?? "";
const rawMode = opt("mode").toLowerCase();
const mode: InlineMode =
rawMode === "kanban" || rawMode === "kanban-genre" ? "kanban-genre"
: rawMode === "cards" || rawMode === "card" || rawMode === "library" ? "library"
: "table";
const heightRaw = parseInt(opt("height"), 10);
return {
file: opt("file"),
mode,
height: Number.isFinite(heightRaw) && heightRaw > 0 ? heightRaw : null,
collapse: opt("collapse").split(",").map(s => s.trim()).filter(Boolean),
};
}
/**
* Inline host for the CSV renderers. Tied to the markdown block's lifecycle
* via MarkdownRenderChild (ctx.addChild onunload on block teardown), which
* also gives it registerEvent for the vault `modify` listener. It re-implements
* the model/interaction surface CardView exposes the renderers reach the same
* members on either, so renderTable/renderLibrary/renderKanban work unchanged.
*/
export class InlineCardHost extends MarkdownRenderChild {
app: App;
settings: CardViewSettings;
persistSettings: () => Promise<void>;
file: TFile | null = null;
private opts: BlockOptions;
// Component for MarkdownRenderer.render (notes preview / expander markdown).
// MarkdownRenderChild is itself a Component, so `this` works — kept as a
// named field to read the same as CardView's renderComponent call sites.
private get renderComponent(): Component { return this; }
headers: string[] = [];
rows: CSVRow[] = [];
mode: ViewMode;
searchQuery = "";
tableSortCol: string | null = null;
tableSortDir: "asc" | "desc" = "asc";
libraryStatusFilter = "all";
libraryGenreFilter = "all";
// Group values (lowercased) collapsed by default in Cards view — from the
// block's `collapse:` directive. Read by renderLibrary.
collapsedGroups: Set<string> = new Set();
private saveTimer: number | null = null;
// Serialized form of our own last write. On a vault `modify` event we re-read
// the file and skip the re-render when the content matches — that's how we
// ignore our own saves (no focus-stealing mid-edit) while still re-syncing
// when another view (a .csv tab, another block) changes the file.
private lastWritten: string | null = null;
private contentArea: HTMLElement | null = null;
constructor(
containerEl: HTMLElement,
app: App,
settings: CardViewSettings,
persistSettings: () => Promise<void>,
opts: BlockOptions,
) {
super(containerEl);
this.app = app;
this.settings = settings;
this.persistSettings = persistSettings;
this.opts = opts;
this.mode = opts.mode;
this.collapsedGroups = new Set(opts.collapse.map(s => s.toLowerCase()));
}
// contentEl is the block root the renderers query for `.csv-content-area`
// and pass to showSelectPicker. MarkdownRenderChild gives us containerEl.
get contentEl(): HTMLElement { return this.containerEl; }
// The renderers + effectiveGroupCol are typed against CardView. InlineCardHost
// satisfies the duck-typed subset they actually reach (verified by the smoke
// tests, which drive the same renderers with plain object literals), but it
// isn't a structural CardView, so the cast is explicit and centralized here.
private get asView(): CardView { return this as unknown as CardView; }
async onload(): Promise<void> {
this.containerEl.addClass("csv-inline-view");
if (!this.opts.file) {
this.renderError("No file specified. Use: file: yourfile.csv");
return;
}
if (!(await this.reload())) return;
this.renderView();
// Re-sync when the source file changes underneath us (edited in a .csv
// tab, another csv-view block, or external sync). Skip our own writes.
this.registerEvent(this.app.vault.on("modify", async (f) => {
if (!this.file || f.path !== this.file.path) return;
const text = await this.app.vault.read(this.file);
if (text === this.lastWritten) return; // our own save — already rendered
const parsed = parseCSV(text);
this.headers = parsed.headers;
this.rows = parsed.rows;
this.renderView();
}));
// A rename of the source file invalidates our cached handle.
this.registerEvent(this.app.vault.on("rename", (f, oldPath) => {
if (this.file && oldPath === this.file.path) this.file = asFile(f);
}));
}
onunload(): void {
if (this.saveTimer) window.clearTimeout(this.saveTimer);
}
/** Read the source file (path already resolved at registration). Returns false (and renders an error) on failure. */
private async reload(): Promise<boolean> {
const file = asFile(this.app.vault.getAbstractFileByPath(this.opts.file));
if (!file) {
this.renderError(`File not found: ${this.opts.file}`);
return false;
}
this.file = file;
try {
const parsed = parseCSV(await this.app.vault.read(file));
this.headers = parsed.headers;
this.rows = parsed.rows;
} catch (e) {
this.renderError(`Error reading file: ${e instanceof Error ? e.message : String(e)}`);
return false;
}
return true;
}
private renderError(msg: string): void {
this.containerEl.empty();
this.containerEl.createEl("p", { text: `csv-view: ${msg}`, cls: "csv-add-error" });
}
// ── Save ─────────────────────────────────────────────────────────────────
scheduleSave(): void {
if (this.saveTimer) window.clearTimeout(this.saveTimer);
this.saveTimer = window.setTimeout(() => void this.doSave(), 600);
}
private async doSave(): Promise<void> {
if (!this.file) return;
try {
const csv = Papa.unparse(this.rows, { columns: this.headers });
this.lastWritten = csv; // suppress the echo `modify` event from re-rendering
await this.app.vault.modify(this.file, csv);
} catch (e) {
new Notice(`Couldn't save ${this.file.name}: ${e instanceof Error ? e.message : String(e)}`, 8000);
}
}
// ── Per-file config (shared with the full view via settings.fileConfigs) ───
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;
void this.persistSettings();
}
// ── Column detection (mirrors CardView) ────────────────────────────────────
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 {
if (this.fileCfg.notesColumn) return this.fileCfg.notesColumn;
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 (notesCol) return h === notesCol;
return this.settings.notesColumns.some(n => n.toLowerCase() === h.toLowerCase());
}
isSelectCol(h: string): boolean {
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): string { const k = this.titleKey(); return (k ? row[k] : row[this.headers[0]]) ?? "—"; }
getSubtitle(row: CSVRow): string { const k = this.authorKey(); return k ? row[k] ?? "" : ""; }
getColumnValues(h: string): string[] {
return Array.from(new Set(this.rows.map(r => r[h] ?? "").filter(Boolean))).sort();
}
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);
}
// Habit/0-1 columns — configured list or auto-detected (all values 0/1/
// true/false/yes/no/empty). Drives the add-form toggles.
getBooleanColumns(): string[] {
if (this.fileCfg.habitColumns?.length) return this.fileCfg.habitColumns.filter(h => this.headers.includes(h));
const boolPatterns = ["0", "1", "true", "false", "yes", "no", ""];
return this.headers.filter(h => {
if (h === this.getDateCol() || this.isNotesCol(h)) return false;
return this.rows.map(r => (r[h] ?? "").toLowerCase().trim()).every(v => boolPatterns.includes(v));
});
}
getDateCol(): string | null {
if (this.headers.length === 0) return null;
const firstCol = this.headers[0];
if (["date", "day", "datum"].includes(firstCol.toLowerCase())) return firstCol;
const datePattern = /^\d{4}-\d{2}-\d{2}$/;
const sample = this.rows.slice(0, 5);
if (sample.length > 0 && sample.every(r => datePattern.test(r[firstCol] ?? ""))) return firstCol;
return null;
}
getFilteredRows(): CSVRow[] {
let result = this.rows;
if (this.searchQuery.trim()) {
const query = this.searchQuery.toLowerCase().trim();
result = result.filter(row => this.headers.some(h => (row[h] ?? "").toLowerCase().includes(query)));
}
if (this.mode === "table" && this.tableSortCol && this.headers.includes(this.tableSortCol)) {
const col = this.tableSortCol, dir = this.tableSortDir;
return [...result].sort((a, b) => {
const cmp = (a[col] ?? "").localeCompare(b[col] ?? "", undefined, { numeric: true });
return dir === "asc" ? cmp : -cmp;
});
}
const dateCol = this.getDateCol();
if (this.mode === "table" && dateCol) {
const sortNewest = this.fileCfg.sortNewestFirst ?? true;
result = [...result].sort((a, b) => {
const cmp = (a[dateCol] ?? "").localeCompare(b[dateCol] ?? "");
return sortNewest ? -cmp : cmp;
});
}
return result;
}
// ── Notes sidecar files ─────────────────────────────────────────────────
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): boolean {
return !!this.app.vault.getAbstractFileByPath(this.notesFilePath(row));
}
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, '\\"')}"`);
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);
}
// ── Row interactions (mirror CardView; render into this block) ─────────────
deleteWithUndo(row: CSVRow): void {
const idx = this.rows.indexOf(row);
if (idx < 0) return;
this.rows.splice(idx, 1);
this.scheduleSave();
this.renderView();
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");
this.rows.splice(Math.min(idx, this.rows.length), 0, row);
this.scheduleSave();
this.renderView();
notice.hide();
new Notice(`Restored “${title}`, 2500);
});
}
openRowContextMenu(row: CSVRow, e: MouseEvent): void {
const menu = new Menu();
menu.addItem(i => i.setTitle("Open / Create Notes file").setIcon("file-text").onClick(() => void this.openOrCreateNotes(row)));
// 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.renderView(); }));
});
}
}
menu.addSeparator();
menu.addItem(i => i.setTitle("Delete").setIcon("trash").onClick(() => this.deleteWithUndo(row)));
menu.showAtMouseEvent(e);
}
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) => { Object.assign(row, updatedRow); this.scheduleSave(); this.renderView(); },
() => this.deleteWithUndo(row),
).open();
}
openAddModal(): void {
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();
this.renderView();
new Notice(`Added: ${this.getTitle(row)}`);
},
{},
// Habit/0-1 columns render as toggles in the add form.
(h) => this.getBooleanColumns().includes(h),
).open();
}
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 || "—");
this.scheduleSave();
}, this.contentEl);
});
return chip;
}
/** Render markdown text into an element, tied to this block's lifecycle. */
renderMarkdownInto(el: HTMLElement, text: string): void {
void MarkdownRenderer.render(this.app, text, el, this.file?.path ?? "", this.renderComponent);
}
// The full view debounces+preserves scroll across a leaf re-render; inline
// blocks are short, so a plain re-render reads fine. Alias keeps the
// renderers' `renderViewPreservingScroll()` calls working.
renderViewPreservingScroll(): void { this.renderView(); }
// ── Render ─────────────────────────────────────────────────────────────────
renderView(contentOnly = false): void {
const root = this.containerEl;
if (!contentOnly) {
root.empty();
this.renderToolbar(root);
this.contentArea = root.createDiv({ cls: "csv-content-area" });
if (this.opts.height) this.contentArea.style.maxHeight = this.opts.height + "px";
} else if (this.contentArea) {
this.contentArea.empty();
}
const content = this.contentArea;
if (!content) return;
content.toggleClass("csv-content-area--no-yscroll", this.mode === "kanban-genre" || this.mode === "table");
if (!this.headers.length) {
content.createDiv({ cls: "csv-empty-state" }).createEl("p", { text: "This file is empty." });
return;
}
if (this.rows.length === 0) {
const wrap = content.createDiv({ cls: "csv-empty-state" });
wrap.createEl("p", { text: "No entries yet." });
wrap.createEl("button", { cls: "csv-empty-state-action", text: "+ Add the first entry" })
.addEventListener("click", () => this.openAddModal());
return;
}
// Cards/Kanban need a groupable column; fall back to table if there isn't one.
if ((this.mode === "library" || this.mode === "kanban-genre") && !effectiveGroupCol(this.asView)) {
this.mode = "table";
}
if (this.mode === "library") renderLibrary(this.asView, content);
else if (this.mode === "kanban-genre") renderKanbanGenre(this.asView, content);
else renderTable(this.asView, content);
}
private renderToolbar(root: HTMLElement): void {
const bar = root.createDiv({ cls: "csv-toolbar csv-inline-toolbar" });
bar.createDiv({ cls: "csv-toolbar-title", text: this.file?.basename ?? "" })
.addEventListener("click", () => { if (this.file) void this.app.workspace.getLeaf("tab").openFile(this.file); });
const ctrl = bar.createDiv({ cls: "csv-toolbar-controls" });
ctrl.createDiv({ cls: "csv-row-count", text: `${this.rows.length} entries` });
// Mode segmented control. Cards/Kanban only when a groupable column exists.
const groupable = !!effectiveGroupCol(this.asView);
const mg = ctrl.createDiv({ cls: "csv-mode-group" });
MODE_LABELS.forEach(({ id, label }) => {
if ((id === "library" || id === "kanban-genre") && !groupable) return;
const btn = mg.createEl("button", { cls: `csv-mode-btn ${this.mode === id ? "active" : ""}`, text: label });
btn.addEventListener("click", () => { this.mode = id; this.renderView(); });
});
// Search.
const searchInput = ctrl.createEl("input", {
cls: "csv-search-input", type: "text", placeholder: "Search…", value: this.searchQuery,
attr: { inputmode: "search", enterkeyhint: "search", autocomplete: "off" },
});
let debounce: number | null = null;
searchInput.addEventListener("input", e => {
this.searchQuery = (e.target as HTMLInputElement).value;
if (debounce !== null) window.clearTimeout(debounce);
debounce = window.setTimeout(() => { debounce = null; this.renderView(true); }, 120);
});
ctrl.createEl("button", { cls: "csv-add-btn", text: "+ Add" }).addEventListener("click", () => this.openAddModal());
}
}
/**
* csv-view block processor. Each block gets its own host, tied to the block's
* lifecycle via ctx.addChild (re-rendered/removed host.onunload).
*/
export function registerCsvViewBlock(
app: App,
settings: CardViewSettings,
persistSettings: () => Promise<void>,
register: (lang: string, handler: (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => void) => void,
): void {
register("csv-view", (source, el, ctx) => {
const opts = parseBlockSource(source);
// Resolve the source file against the note that holds the block — captured
// here from ctx, since the host only keeps the active-file folder as a
// fallback. We pre-resolve by stashing the note folder on opts.file when
// it's a bare/relative path so reload() resolves correctly.
const noteFolder = app.vault.getAbstractFileByPath(ctx.sourcePath)?.parent?.path ?? "";
const resolved = opts.file ? resolvePath(opts.file, noteFolder) : opts.file;
const host = new InlineCardHost(el, app, settings, persistSettings, { ...opts, file: resolved });
ctx.addChild(host);
});
}

View file

@ -6,7 +6,7 @@ import {
Notice,
} from "obsidian";
import { CSVRow, FileConfig, ViewMode } from "./types";
import { showSelectPicker, titleCase, isMultiValueColName } from "./utils";
import { showSelectPicker, titleCase, isMultiValueColName, looksCategorical } from "./utils";
import { suggestionsFor, isDateCol, ISO_DATE } from "./field-types";
// ─── Shared field input ─────────────────────────────────────────────────────
@ -47,6 +47,10 @@ export class AddEntryModal extends Modal {
isSelectCol: (h: string) => boolean;
getColumnValues: (h: string) => string[];
onSubmit: (row: CSVRow) => void;
// True for 0/1 habit-style columns — they render as a toggle instead of a
// text field so logging a day is a tap, not typing "1". Optional so callers
// that don't track habits can omit it (defaults to never-boolean).
isBooleanCol: (h: string) => boolean;
// Predefined options per column (keyed by header). Merged ahead of the
// file's existing values in the dropdown — e.g. a tasks file passes
// { Type: ["Task","Note","Idea"], Priority: ["Low","Medium","High"] } so a
@ -60,7 +64,8 @@ export class AddEntryModal extends Modal {
isSelectCol: (h: string) => boolean,
getColumnValues: (h: string) => string[],
onSubmit: (row: CSVRow) => void,
optionPresets: Record<string, string[]> = {}
optionPresets: Record<string, string[]> = {},
isBooleanCol: (h: string) => boolean = () => false
) {
super(app);
this.headers = headers;
@ -69,6 +74,7 @@ export class AddEntryModal extends Modal {
this.getColumnValues = getColumnValues;
this.onSubmit = onSubmit;
this.optionPresets = optionPresets;
this.isBooleanCol = isBooleanCol;
}
onOpen(): void {
@ -107,9 +113,29 @@ export class AddEntryModal extends Modal {
// modal's focus-trap, which on an empty column made the field unfillable
// (it bounced back to the title). Multi-value columns (tags/genres) still
// use the chip picker since a native select can't multi-select cleanly.
const useNativeSelect = !this.isNotesCol(h) && (presets.length > 0 || (this.isSelectCol(h) && !isMultiValueColName(h)));
// Pseudo-categorical: an un-configured column whose existing values are
// few enough to be a closed-ish vocabulary (e.g. Watched, Format) gets a
// dropdown of its values + "Custom…" too — not just the names listed in
// settings.selectColumns. Same heuristic as the mobile add form.
const autoCategorical = !this.isSelectCol(h) && !isMultiValueColName(h)
&& !isDateCol(h) && looksCategorical(this.getColumnValues(h).length);
const useNativeSelect = !this.isNotesCol(h) && (presets.length > 0 || (this.isSelectCol(h) && !isMultiValueColName(h)) || autoCategorical);
if (this.isNotesCol(h)) {
if (this.isBooleanCol(h)) {
// Habit-style 0/1 column → a toggle. Off writes "0", on "1", so logging
// a day is a tap per habit instead of typing each value. Self-contained
// (own knob + CSS) rather than Obsidian's .checkbox-container so it
// renders consistently across themes.
values[h] = "0";
const toggle = row.createDiv({ cls: "csv-toggle" });
toggle.createDiv({ cls: "csv-toggle-knob" });
toggle.addEventListener("click", () => {
const on = !toggle.hasClass("is-on");
toggle.toggleClass("is-on", on);
values[h] = on ? "1" : "0";
});
} else if (this.isNotesCol(h)) {
const ta = row.createEl("textarea", { cls: "csv-modal-textarea", placeholder: "Markdown supported…" });
ta.addEventListener("input", () => { values[h] = ta.value; });
@ -296,7 +322,12 @@ export class NoteExpanderModal extends Modal {
// titleCase: Apple-style row labels, independent of CSV header casing.
fieldRow.createDiv({ cls: "csv-expander-field-label", text: titleCase(h) });
if (this.isSelectCol(h)) {
// Configured select column, or a pseudo-categorical one (few distinct
// values, e.g. Watched/Format) — both get the chip + option picker, so
// editing offers the same vocabulary the add form does.
const selectLike = this.isSelectCol(h)
|| (!isMultiValueColName(h) && !isDateCol(h) && looksCategorical(this.getColumnValues(h).length));
if (selectLike) {
const chip = fieldRow.createDiv({ cls: `csv-select-chip ${this.row[h] ? "" : "empty"}` });
chip.setText(this.row[h] || "—");
chip.addEventListener("click", e => {
@ -325,63 +356,71 @@ export class NoteExpanderModal extends Modal {
// click-to-edit pattern as the kanban card preview). Links and embeds
// inside the markdown stay clickable — we suppress the edit-swap only
// when the click landed on an anchor or the user is selecting text.
const notesDivider = contentEl.createDiv({ cls: "csv-expander-divider" });
notesDivider.createDiv({ cls: "csv-expander-notes-label", text: this.notesCol });
// Notes section is only rendered when the file actually has a notes column.
// Files without one (e.g. an applications tracker that's all structured
// fields) still open the expander to edit every other field — the notes
// editor just isn't shown. `ta` stays null in that case; the Save handler
// guards on this.notesCol before writing back.
let isEditing = false;
let currentText = this.row[this.notesCol] ?? "";
let currentText = this.notesCol ? (this.row[this.notesCol] ?? "") : "";
let ta: HTMLTextAreaElement | null = null;
const rendered = contentEl.createDiv({ cls: "csv-expander-rendered markdown-rendered" });
rendered.title = "Click to edit";
const editorWrap = contentEl.createDiv({ cls: "csv-expander-editor" });
editorWrap.style.display = "none";
if (this.notesCol) {
const notesDivider = contentEl.createDiv({ cls: "csv-expander-divider" });
notesDivider.createDiv({ cls: "csv-expander-notes-label", text: this.notesCol });
const renderMarkdown = () => {
rendered.empty();
if (currentText.trim()) {
MarkdownRenderer.render(this.app, currentText, rendered, this.filePath, this.renderComponent);
} else {
rendered.createDiv({ cls: "csv-notes-empty", text: "+ Add note" });
}
};
renderMarkdown();
const ta = editorWrap.createEl("textarea", { cls: "csv-expander-textarea" });
ta.value = currentText;
ta.addEventListener("input", () => { currentText = ta.value; });
const enterEdit = () => {
if (isEditing) return;
isEditing = true;
rendered.style.display = "none";
editorWrap.style.display = "flex";
ta.value = currentText;
ta.focus();
};
const exitEdit = () => {
if (!isEditing) return;
isEditing = false;
const rendered = contentEl.createDiv({ cls: "csv-expander-rendered markdown-rendered" });
rendered.title = "Click to edit";
const editorWrap = contentEl.createDiv({ cls: "csv-expander-editor" });
editorWrap.style.display = "none";
rendered.style.display = "";
currentText = ta.value;
renderMarkdown();
};
rendered.addEventListener("click", (e) => {
// Don't hijack clicks on links, buttons, or other interactive children —
// they should open as the user expects.
const target = e.target as HTMLElement;
if (target.closest("a, button, input, textarea, [contenteditable]")) return;
// Don't enter edit mode if the user just finished a text selection inside
// the rendered note; respects normal text-select semantics.
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
enterEdit();
});
// Esc inside the textarea returns to the preview. Click outside (blur)
// also exits — keeps the modal feeling lightweight.
ta.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); ta.blur(); } });
ta.addEventListener("blur", exitEdit);
const renderMarkdown = () => {
rendered.empty();
if (currentText.trim()) {
MarkdownRenderer.render(this.app, currentText, rendered, this.filePath, this.renderComponent);
} else {
rendered.createDiv({ cls: "csv-notes-empty", text: "+ Add note" });
}
};
renderMarkdown();
ta = editorWrap.createEl("textarea", { cls: "csv-expander-textarea" });
ta.value = currentText;
ta.addEventListener("input", () => { currentText = ta!.value; });
const enterEdit = () => {
if (isEditing) return;
isEditing = true;
rendered.style.display = "none";
editorWrap.style.display = "flex";
ta!.value = currentText;
ta!.focus();
};
const exitEdit = () => {
if (!isEditing) return;
isEditing = false;
editorWrap.style.display = "none";
rendered.style.display = "";
currentText = ta!.value;
renderMarkdown();
};
rendered.addEventListener("click", (e) => {
// Don't hijack clicks on links, buttons, or other interactive children —
// they should open as the user expects.
const target = e.target as HTMLElement;
if (target.closest("a, button, input, textarea, [contenteditable]")) return;
// Don't enter edit mode if the user just finished a text selection inside
// the rendered note; respects normal text-select semantics.
const sel = window.getSelection();
if (sel && sel.toString().length > 0) return;
enterEdit();
});
// Esc inside the textarea returns to the preview. Click outside (blur)
// also exits — keeps the modal feeling lightweight.
ta.addEventListener("keydown", (e) => { if (e.key === "Escape") { e.preventDefault(); ta!.blur(); } });
ta.addEventListener("blur", exitEdit);
}
// ── Footer buttons ───────────────────────────────────────────────────────
// Layout: [Delete] ............... [Cancel] [Save & close]
@ -407,8 +446,11 @@ export class NoteExpanderModal extends Modal {
.addEventListener("click", () => {
// If still in edit mode (user clicked Save without blurring), grab
// the live textarea content; otherwise currentText is already fresh.
if (isEditing) currentText = ta.value;
this.row[this.notesCol] = currentText;
// Only write back when there's a notes column (ta is null otherwise).
if (this.notesCol) {
if (isEditing && ta) currentText = ta.value;
this.row[this.notesCol] = currentText;
}
this.onSave(this.row);
this.close();
});

View file

@ -42,6 +42,10 @@ export interface FileConfig {
kanbanGroupCol?: string; // Kanban "Group by" column. Unset = category column.
// Year-like columns bucket into decades.
librarySort?: LibrarySort; // Card-view section ordering. Unset = "status".
imageColumn?: string; // Column holding an image (path/wikilink/URL) for
// card/kanban thumbnails. Unset = auto-detect by name.
collapsedGroups?: string[]; // Card-view group values (lowercased) collapsed by
// default; remembers manual collapse/expand toggles.
ankiFrontCol?: string; // Column used as the Anki card front on sync.
// Unset = the title/primary field; every other
// non-empty column becomes the card back.

View file

@ -1,6 +1,46 @@
import Papa from "papaparse";
import { App, TFile } from "obsidian";
import { CSVRow } from "./types";
// Column-name aliases that mark a cell as holding an image, for card/kanban
// thumbnails. Matched case-insensitively against headers (see getImageCol).
export const IMAGE_COL_ALIASES = ["Image", "image", "Cover", "cover", "Poster", "poster", "Thumbnail", "thumbnail", "Thumb", "thumb", "Photo", "photo", "Picture", "picture", "Img", "img"];
/**
* Resolve a cell value into an <img> src, or null if it isn't resolvable.
* Accepts: http(s)/data URLs (used as-is), `![[wikilink]]` / `[[wikilink]]`,
* `![](path)` markdown images, and bare vault paths / filenames (resolved
* relative to the CSV's own path via the link cache getResourcePath).
*/
export function resolveImageSrc(app: App, raw: string, sourcePath: string): string | null {
let v = (raw ?? "").trim();
if (!v) return null;
// Absolute URLs / data URIs pass through untouched.
if (/^(https?:|data:)/i.test(v)) return v;
// Unwrap ![[wikilink]] / [[wikilink]] and ![](path) markdown image syntax.
const wiki = v.match(/!?\[\[([^\]|]+)(?:\|[^\]]*)?\]\]/);
if (wiki) v = wiki[1].trim();
else {
const md = v.match(/!\[[^\]]*\]\(([^)]+)\)/);
if (md) v = md[1].trim();
}
if (/^(https?:|data:)/i.test(v)) return v; // an md-image whose target was a URL
// Resolve as a vault link relative to the CSV file, then to a usable src.
const file = app.metadataCache.getFirstLinkpathDest(v, sourcePath);
if (file instanceof TFile) return app.vault.getResourcePath(file);
return null;
}
// Largest distinct-value count at which a column still "looks categorical" —
// i.e. worth an option picker rather than a free-text input. The single source
// of truth for the add form, the entry editor, and the mobile add form, so all
// three offer the same pseudo-categorical columns (e.g. Watched, Format) the
// same way, not just the names configured in settings.selectColumns.
export const CATEGORICAL_MAX_DISTINCT = 15;
export function looksCategorical(distinctCount: number): boolean {
return distinctCount >= 1 && distinctCount <= CATEGORICAL_MAX_DISTINCT;
}
export function sanitizeFilename(name: string): string {
return name.replace(/[\\/:*?"<>|#^[\]]/g,"").replace(/\s+/g," ").trim().slice(0,100);
}

View file

@ -4,7 +4,7 @@
import type { CardView } from "../../main";
import { CSVRow } from "../types";
import { showSelectPicker, isMultiValueColName, isYearLikeColumn, decadeLabel, pickFallbackGroupCol } from "../utils";
import { showSelectPicker, isMultiValueColName, isYearLikeColumn, decadeLabel, pickFallbackGroupCol, resolveImageSrc } from "../utils";
/**
* The column Cards/Kanban group by, resolved in priority order:
@ -66,11 +66,9 @@ export function renderKanbanGenre(view: CardView, container: HTMLElement): void
// doesn't explode into 40 single-year columns. Everything else keeps the
// comma-split multi-value behavior the genre kanban always had.
const isYear = isYearLikeColumn(cc, filteredRows.map(r => r[cc] ?? ""));
// When the user explicitly grouped by a non-default column, rows with an
// empty value get a "—" bucket instead of silently vanishing (an explicit
// grouping should account for every row). The default genre view keeps
// its original drop-empties behavior.
const explicit = cc !== defaultCc;
// Rows with an empty group value get a "—" bucket instead of silently
// vanishing — every row should land in some column, including the default
// genre view (a movie with no genre still belongs on the board).
const groupValues = (row: CSVRow): string[] => {
const raw = row[cc] ?? "";
let vals: string[];
@ -80,7 +78,7 @@ export function renderKanbanGenre(view: CardView, container: HTMLElement): void
} else {
vals = raw.split(",").map(s=>s.trim()).filter(Boolean);
}
if (!vals.length && explicit) vals = ["—"];
if (!vals.length) vals = ["—"];
return vals;
};
@ -120,6 +118,12 @@ export function renderKanbanGenre(view: CardView, container: HTMLElement): void
groupEl.createDiv({cls:`csv-kanban-status-label status-${status.toLowerCase().replace(/\s+/g,"-")}`, text:status});
statusRows.forEach(row => renderKanbanCard(view, groupEl, row, sc, cc));
});
// Rows whose status is blank or not among the known statuses (the empty
// strings dropped by `.filter(Boolean)` above) still belong in this
// column — render them ungrouped so they don't vanish while the column
// header still counts them.
const known = new Set(statuses);
genreRows.filter(r => !known.has(r[sc] ?? "")).forEach(row => renderKanbanCard(view, cb, row, sc, cc));
} else {
genreRows.forEach(row => renderKanbanCard(view, cb, row, sc, cc));
}
@ -130,14 +134,25 @@ function renderKanbanCard(view: CardView, container: HTMLElement, row: CSVRow, s
const card = container.createDiv({cls:"csv-kanban-card"});
const notesColForCard = view.getNotesCol();
// Thumbnail (when an image column resolves). Lazy-loaded; broken srcs drop out.
const imageCol = view.getImageCol?.() ?? null;
if (imageCol) {
const src = resolveImageSrc(view.app, row[imageCol] ?? "", view.file?.path ?? "");
if (src) {
const img = card.createEl("img", { cls: "csv-kanban-card-img", attr: { src, loading: "lazy", alt: "" } });
img.addEventListener("error", () => img.remove());
}
}
// Title row: title text on the left, small notes-file icon on the right.
// Tapping the title opens the entry expander; the small icon creates or
// opens the sidecar .md. Replaces the old hover-revealed bottom button row.
const titleRow = card.createDiv({cls:"csv-kanban-card-title-row"});
const titleEl = titleRow.createDiv({cls:"csv-kanban-card-title", text:view.getTitle(row)});
if (notesColForCard) {
titleEl.addEventListener("click", e => { e.stopPropagation(); view.openNoteExpander(row, notesColForCard); });
}
// Tapping the title opens the entry editor. Works even when the file has no
// notes column (e.g. an applications tracker) — the expander still edits
// every structured field; its notes section just doesn't render.
titleEl.addEventListener("click", e => { e.stopPropagation(); view.openNoteExpander(row, notesColForCard ?? ""); });
const hasNotesFile = view.notesFileExists(row);
const notesIconBtn = titleRow.createEl("button", {
cls: `csv-kanban-notes-icon ${hasNotesFile ? "exists" : ""}`,

View file

@ -4,7 +4,7 @@
import type { CardView } from "../../main";
import { CSVRow, LibrarySort } from "../types";
import { formatRatingForDisplay } from "../utils";
import { formatRatingForDisplay, resolveImageSrc } from "../utils";
import { parseRating } from "./stats";
import { effectiveGroupCol } from "./kanban";
@ -174,7 +174,26 @@ export function renderLibrary(view: CardView, container: HTMLElement): void {
Object.keys(groups).sort().forEach(genre => {
const items = groups[genre];
const section = sectionsWrap.createEl("details", { cls: "csv-library-section" });
section.open = true;
// Collapsed by default if this group is in the inline `collapse:` directive
// (view.collapsedGroups) OR the file's remembered collapsed set
// (fileCfg.collapsedGroups). Manual toggles below persist to the latter, so
// collapse works — and is remembered — in the full-page Cards view too.
const gKey = genre.toLowerCase();
const persisted = (view.fileCfg.collapsedGroups ?? []).map(s => s.toLowerCase());
section.open = !(view.collapsedGroups?.has(gKey) || persisted.includes(gKey));
section.addEventListener("toggle", () => {
const cfg = view.fileCfg;
const cur = (cfg.collapsedGroups ?? []).map(s => s.toLowerCase());
const set = new Set(cur);
if (section.open) set.delete(gKey); else set.add(gKey);
const next = Array.from(set);
// Skip the write when nothing changed — guards against the spurious
// toggle event fired by the initial programmatic `section.open` set, so
// a render doesn't rewrite data.json every time.
if (next.length === cur.length && next.every(x => cur.includes(x))) return;
cfg.collapsedGroups = next;
view.saveFileCfg(cfg);
});
const summary = section.createEl("summary", { cls: "csv-library-section-header" });
summary.innerHTML = `<span class="csv-library-arrow">▶</span> ${genre} <span class="csv-library-count">${items.length}</span>`;
@ -227,10 +246,21 @@ export function renderLibrary(view: CardView, container: HTMLElement): void {
// Otherwise auto-detect: author, year, rating, theme.
const autoFields = [authorCol, yearCol, ratingCol, themeCol].filter((c): c is string => !!c);
const cardFields = view.fileCfg.cardFields ?? autoFields;
const imageCol = view.getImageCol?.() ?? null;
items.forEach(row => {
const card = grid.createDiv({ cls: "csv-library-card" });
// Cover image (when an image column resolves to a usable src). Lazy so a
// genre section with many cards doesn't fetch every image up front.
if (imageCol) {
const src = resolveImageSrc(view.app, row[imageCol] ?? "", view.file?.path ?? "");
if (src) {
const img = card.createEl("img", { cls: "csv-library-card-img", attr: { src, loading: "lazy", alt: "" } });
img.addEventListener("error", () => img.remove()); // drop broken images quietly
}
}
// Title with green dot for "done"-style status (watched, read, finished, etc.)
const titleWrap = card.createDiv({ cls: "csv-library-card-title" });
if (sc) {
@ -281,12 +311,10 @@ export function renderLibrary(view: CardView, container: HTMLElement): void {
});
}
// Click to expand
// Click to expand. Opens even without a notes column — the expander
// edits every structured field; its notes section just doesn't render.
card.addEventListener("click", () => {
const notesCol = view.getNotesCol();
if (notesCol) {
view.openNoteExpander(row, notesCol);
}
view.openNoteExpander(row, view.getNotesCol() ?? "");
});
card.addEventListener("contextmenu", e => view.openRowContextMenu(row, e));
});

View file

@ -923,8 +923,14 @@
otherwise leaves the last-touched row tinted and forces a recomposite
per scroll frame a major cause of table lag on iPhone. */
@media (hover: hover) {
/* `--background-modifier-hover` is barely perceptible in dark themes, so the
row looked like only some cells lit up. Use a text-tinted overlay that
reads clearly in both light and dark; the plain var is the fallback for
engines without color-mix. The row highlights uniformly because every
cell (incl. the action cell) is a <td>. */
.csv-table tr:hover td { background: var(--background-modifier-hover); }
.csv-table tr:hover td.csv-cell--clipped::after { background: linear-gradient(to bottom, transparent, var(--background-modifier-hover)); }
.csv-table tr:hover td { background: color-mix(in srgb, var(--text-normal) 16%, transparent); }
.csv-table tr:hover td.csv-cell--clipped::after { background: linear-gradient(to bottom, transparent, color-mix(in srgb, var(--text-normal) 16%, transparent)); }
}
/* Long-content fade-out hint, scoped to cells JS measured as overflowing.
The pseudo-element previously existed on every td (opacity:0 by default)
@ -3841,3 +3847,123 @@ select.csv-add-row-control {
background-position: right 10px center;
padding-right: 28px;
}
/* Inline csv-view block
The `csv-view` code block embeds a table/cards/kanban view inside a note
(a .base / Notion-DB-style embed). The full view fills its leaf via flex:1;
inline there's no such parent, so the wrapper establishes a bounded, self-
contained box: the toolbar reuses .csv-toolbar chrome, the content area
scrolls internally, and kanban columns get a definite height to size to. */
.csv-inline-view {
display: flex;
flex-direction: column;
max-height: 600px; /* `height:` directive overrides via inline max-height on .csv-content-area */
margin: 0.5em 0;
border: 1px solid var(--csv-border);
border-radius: 10px;
overflow: hidden;
background: var(--background-primary);
box-shadow: var(--csv-shadow);
}
/* Content area fills the remaining height so internal scroll works and the
kanban board (flex:1) has a parent height to stretch into. */
.csv-inline-view .csv-content-area {
flex: 1 1 auto;
min-height: 0;
padding: 12px;
}
/* Compact toolbar slimmer than the full-view bar, and the title is a
click target that opens the source .csv in its own tab. */
.csv-inline-toolbar {
padding: 8px 12px;
}
.csv-inline-toolbar .csv-toolbar-title {
display: block;
cursor: pointer;
text-decoration: none;
}
.csv-inline-toolbar .csv-toolbar-title:hover {
color: var(--csv-accent);
text-decoration: underline;
}
/* Mode segmented control (Table / Cards / Kanban). The full view uses a
<select>; inline uses buttons so the modes are one tap away. */
.csv-inline-toolbar .csv-mode-group {
display: inline-flex;
gap: 2px;
padding: 2px;
background: var(--csv-surface);
border: 1px solid var(--csv-border);
border-radius: 7px;
}
.csv-mode-btn {
padding: 3px 10px;
font-size: 12px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text-muted);
cursor: pointer;
box-shadow: none;
}
.csv-mode-btn:hover {
color: var(--text-normal);
background: var(--background-modifier-hover);
}
.csv-mode-btn.active {
background: var(--csv-accent);
color: var(--text-on-accent);
}
/* Card / Kanban image thumbnails
Rendered when a column named Image/Cover/Poster/Thumbnail/Photo (or the
per-file image column) resolves to a usable src. Lazy-loaded; broken srcs
remove themselves so a card never shows a torn-image glyph. */
.csv-library-card-img {
display: block;
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
border-radius: 8px;
margin-bottom: 8px;
background: var(--csv-surface-2);
}
.csv-kanban-card-img {
display: block;
width: 100%;
max-height: 140px;
object-fit: cover;
border-radius: 6px;
margin-bottom: 8px;
background: var(--csv-surface-2);
}
/* Toggle (habit 0/1 columns in the add form)
Self-contained switch so logging a habit day is a tap, not typing 0/1.
On = accent track + knob slid right; writes "1" (off "0"). */
.csv-toggle {
width: 38px;
height: 22px;
border-radius: 11px;
background: var(--background-modifier-border);
position: relative;
cursor: pointer;
transition: background 0.15s ease;
flex: 0 0 auto;
}
.csv-toggle.is-on { background: var(--csv-accent); }
.csv-toggle-knob {
position: absolute;
top: 2px;
left: 2px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--text-on-accent, #fff);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
transition: left 0.15s ease;
}
.csv-toggle.is-on .csv-toggle-knob { left: 18px; }