mirror of
https://github.com/svm0n/datadeck.git
synced 2026-07-22 08:31:46 +00:00
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:
parent
5011fe2433
commit
8518dd7a2f
10 changed files with 937 additions and 106 deletions
44
main.js
44
main.js
File diff suppressed because one or more lines are too long
34
main.ts
34
main.ts
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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
552
src/inline-view.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
160
src/modals.ts
160
src/modals.ts
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
40
src/utils.ts
40
src/utils.ts
|
|
@ -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]]`,
|
||||
* `` 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  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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" : ""}`,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
128
styles.css
128
styles.css
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue