svm0n_datadeck/src/view/library.ts
SVM0N 8518dd7a2f 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>
2026-06-28 17:53:11 +00:00

335 lines
15 KiB
TypeScript

// Library (cards-by-genre) view renderer. Extracted from CardView; the members
// it reaches are public on CardView. Type-only import → no runtime cycle.
// Covered by test-view-smoke.mjs.
import type { CardView } from "../../main";
import { CSVRow, LibrarySort } from "../types";
import { formatRatingForDisplay, resolveImageSrc } from "../utils";
import { parseRating } from "./stats";
import { effectiveGroupCol } from "./kanban";
export function renderLibrary(view: CardView, container: HTMLElement): void {
// Same grouping column as the kanban (per-file pick → category → auto
// fallback), so Cards stays available on files without a category column.
const cc = effectiveGroupCol(view);
const sc = view.getStatusCol();
const titleCol = view.titleKey() ?? view.headers[0];
const authorCol = view.authorKey();
if (!cc) {
container.createEl("p", { text: `No groupable column found.`, cls: "csv-empty-state" });
return;
}
// Collect all genres
const allGenres = new Set<string>();
view.rows.forEach(row => {
const cats = (row[cc] ?? "").split(",").map(c => c.trim()).filter(Boolean);
cats.forEach(c => allGenres.add(c));
});
// Collect all statuses
const allStatuses = new Set<string>();
if (sc) {
view.rows.forEach(row => {
const status = (row[sc] ?? "").trim();
if (status) allStatuses.add(status);
});
}
// Filters bar
const filtersBar = container.createDiv({ cls: "csv-library-filters" });
// Status filter
const statusSelect = filtersBar.createEl("select", { cls: "csv-library-filter-select" });
statusSelect.createEl("option", { text: "All", value: "all" });
// Add common status filters. "yes" and "seen" cover the common
// Watched=yes / Seen=yes boolean patterns used by movie trackers.
const commonDone = ["watched", "read", "finished", "completed", "done", "yes", "seen"];
const commonInProgress = ["watching", "reading", "in progress", "in-progress"];
const hasDone = Array.from(allStatuses).some(s => commonDone.includes(s.toLowerCase()));
const hasInProgress = Array.from(allStatuses).some(s => commonInProgress.includes(s.toLowerCase()));
if (hasDone || hasInProgress) {
statusSelect.createEl("option", { text: "───────", value: "", attr: { disabled: "true" } });
if (hasDone) statusSelect.createEl("option", { text: "✓ Done", value: "__done__" });
if (hasInProgress) statusSelect.createEl("option", { text: "◐ In Progress", value: "__inprogress__" });
statusSelect.createEl("option", { text: "○ Not Started", value: "__notstarted__" });
}
if (allStatuses.size > 0) {
statusSelect.createEl("option", { text: "───────", value: "", attr: { disabled: "true" } });
Array.from(allStatuses).sort().forEach(s => {
statusSelect.createEl("option", { text: s, value: s });
});
}
statusSelect.value = view.libraryStatusFilter;
// Genre filter
const genreSelect = filtersBar.createEl("select", { cls: "csv-library-filter-select" });
genreSelect.createEl("option", { text: "All genres", value: "all" });
Array.from(allGenres).sort().forEach(g => {
genreSelect.createEl("option", { text: g, value: g });
});
genreSelect.value = view.libraryGenreFilter;
// Card-detail columns, resolved once per render (used by the sort selector
// and by the per-card field rendering below).
const yearCol = view.resolveCol(["Year", "year", "Date", "date"]);
const ratingCol = view.resolveCol(["Rating", "rating", "Score", "score", "Score /5", "Stars", "stars"]);
const themeCol = view.resolveCol(["Theme", "theme", "Tags", "tags", "Tag", "tag", "Mood", "mood"]);
// Sort selector — persisted per file. "Status" is the historical default
// (consumed → in-progress → backlog); the others only appear when the
// file actually has the column to sort by.
const sortKey: LibrarySort = view.fileCfg.librarySort ?? "status";
const sortSelect = filtersBar.createEl("select", { cls: "csv-library-filter-select" });
const sortOptions: [LibrarySort, string][] = [["status", "Sort: Status"], ["title", "Sort: Title"]];
if (ratingCol) sortOptions.push(["rating", "Sort: Rating"]);
if (yearCol) sortOptions.push(["year", "Sort: Year"]);
sortOptions.forEach(([val, label]) => {
const opt = sortSelect.createEl("option", { text: label, value: val });
if (val === sortKey) opt.selected = true;
});
sortSelect.addEventListener("change", () => {
const cfg = view.fileCfg;
cfg.librarySort = sortSelect.value === "status" ? undefined : (sortSelect.value as LibrarySort);
view.saveFileCfg(cfg);
view.renderView(true);
});
// Search lives in the toolbar (the 🔍 toggle on mobile, always-visible
// input on desktop). Library used to render its own search input here,
// duplicating the one in the toolbar — both wrote to the same
// view.searchQuery. Removed.
// Filter handlers
const applyFilters = () => {
view.libraryStatusFilter = statusSelect.value;
view.libraryGenreFilter = genreSelect.value;
view.renderView(true);
};
statusSelect.addEventListener("change", applyFilters);
genreSelect.addEventListener("change", applyFilters);
// Filter rows
let filtered = view.rows.filter(row => {
// Status filter
if (view.libraryStatusFilter !== "all" && sc) {
const rowStatus = (row[sc] ?? "").toLowerCase();
if (view.libraryStatusFilter === "__done__") {
if (!commonDone.includes(rowStatus)) return false;
} else if (view.libraryStatusFilter === "__inprogress__") {
if (!commonInProgress.includes(rowStatus)) return false;
} else if (view.libraryStatusFilter === "__notstarted__") {
if (commonDone.includes(rowStatus) || commonInProgress.includes(rowStatus)) return false;
} else {
if (rowStatus !== view.libraryStatusFilter.toLowerCase()) return false;
}
}
// Genre filter
if (view.libraryGenreFilter !== "all") {
const rowGenres = (row[cc] ?? "").split(",").map(c => c.trim().toLowerCase());
if (!rowGenres.includes(view.libraryGenreFilter.toLowerCase())) return false;
}
// Search filter — match any field, consistent with getFilteredRows()
// in the other views (title-only matching here meant "search by author"
// worked in kanban/table but silently failed in the library).
if (view.searchQuery.trim()) {
const q = view.searchQuery.toLowerCase().trim();
if (!view.headers.some(h => (row[h] ?? "").toLowerCase().includes(q))) return false;
}
return true;
});
// Result count
if (view.libraryStatusFilter !== "all" || view.libraryGenreFilter !== "all" || view.searchQuery.trim()) {
container.createDiv({
cls: "csv-library-result-count",
text: `Showing ${filtered.length} of ${view.rows.length} entries`
});
}
// Group by genre
const groups: Record<string, CSVRow[]> = {};
filtered.forEach(row => {
const cats = view.libraryGenreFilter !== "all"
? [view.libraryGenreFilter]
: (row[cc] ?? "Uncategorized").split(",").map(c => c.trim()).filter(Boolean);
if (cats.length === 0) cats.push("Uncategorized");
cats.forEach(cat => {
if (!groups[cat]) groups[cat] = [];
groups[cat].push(row);
});
});
// Render sections
const sectionsWrap = container.createDiv({ cls: "csv-library-sections" });
Object.keys(groups).sort().forEach(genre => {
const items = groups[genre];
const section = sectionsWrap.createEl("details", { cls: "csv-library-section" });
// 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>`;
const grid = section.createDiv({ cls: "csv-library-grid" });
// Section ordering, driven by the sort selector:
// status (default) — green-dotted (read/watched/finished) first, then
// in-progress, then backlog; alphabetical within each band. Surfacing
// what you've already done makes the section read as a library
// catalogue (consumed → backlog) rather than a todo list.
// title — plain alphabetical.
// rating — best first; unrated last.
// year — newest first; undated last.
const byTitle = (a: CSVRow, b: CSVRow) => (a[titleCol] ?? "").localeCompare(b[titleCol] ?? "");
// Descending compare where null (missing value) always sorts last.
const descNullsLast = (va: number | null, vb: number | null): number => {
if (va === null && vb === null) return 0;
if (va === null) return 1;
if (vb === null) return -1;
return vb - va;
};
items.sort((a, b) => {
if (sortKey === "title") return byTitle(a, b);
if (sortKey === "rating" && ratingCol) {
return descNullsLast(parseRating(a[ratingCol] ?? ""), parseRating(b[ratingCol] ?? "")) || byTitle(a, b);
}
if (sortKey === "year" && yearCol) {
const yearOf = (r: CSVRow): number | null => {
const m = (r[yearCol] ?? "").match(/\d{4}/);
return m ? parseInt(m[0], 10) : null;
};
return descNullsLast(yearOf(a), yearOf(b)) || byTitle(a, b);
}
if (sc) {
const statusA = (a[sc] ?? "").toLowerCase();
const statusB = (b[sc] ?? "").toLowerCase();
const doneA = commonDone.includes(statusA);
const doneB = commonDone.includes(statusB);
if (doneA !== doneB) return doneA ? -1 : 1;
const inProgressA = commonInProgress.includes(statusA);
const inProgressB = commonInProgress.includes(statusB);
if (inProgressA !== inProgressB) return inProgressA ? -1 : 1;
}
return byTitle(a, b);
});
// Resolve which extra columns to surface on each card.
// If the user picked cardFields in the per-file Columns modal, use that list verbatim.
// 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) {
const status = (row[sc] ?? "").toLowerCase();
if (commonDone.includes(status)) {
titleWrap.createSpan({ cls: "csv-library-done-dot" });
}
}
titleWrap.createSpan({ text: row[titleCol] ?? "Untitled" });
// Walk cardFields in order, rendering each with the right element type.
// Rating → stars line; theme/tag/category aliases → pills; everything else → meta line.
const metaParts: string[] = [];
const themeFieldsForCard: string[] = [];
for (const col of cardFields) {
if (!col) continue;
const raw = String(row[col] ?? "").trim();
if (!raw) continue;
if (col === ratingCol) {
// Render rating as stars on its own line. Already-star data passes through.
const display = formatRatingForDisplay(raw, col);
if (display) card.createDiv({ cls: "csv-library-card-rating", text: display });
} else if (col === themeCol) {
// Comma-separated theme values render as multiple pills.
themeFieldsForCard.push(...raw.split(",").map(t => t.trim()).filter(Boolean));
} else if (col === yearCol) {
// Year — extract 4-digit if it's a full date.
const m = raw.match(/\d{4}/);
metaParts.push(m ? m[0] : raw);
} else {
metaParts.push(raw);
}
}
if (metaParts.length) {
card.createDiv({ cls: "csv-library-card-meta", text: metaParts.join(" · ") });
}
// Secondary genres render as extra tags when filtering by a single genre.
if (view.libraryGenreFilter !== "all") {
const otherGenres = (row[cc] ?? "").split(",").map(c => c.trim()).filter(c => c && c.toLowerCase() !== view.libraryGenreFilter.toLowerCase());
themeFieldsForCard.push(...otherGenres);
}
if (themeFieldsForCard.length) {
const tagsWrap = card.createDiv({ cls: "csv-library-card-tags" });
themeFieldsForCard.slice(0, 3).forEach(tag => {
tagsWrap.createSpan({ cls: "csv-library-card-tag", text: tag });
});
}
// 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", () => {
view.openNoteExpander(row, view.getNotesCol() ?? "");
});
card.addEventListener("contextmenu", e => view.openRowContextMenu(row, e));
});
});
if (Object.keys(groups).length === 0) {
const empty = sectionsWrap.createDiv({ cls: "csv-empty-state" });
empty.createEl("p", { text: "No entries match your filters." });
const clearBtn = empty.createEl("button", { cls: "csv-clear-filters-btn", text: "Clear filters" });
clearBtn.addEventListener("click", () => {
view.libraryStatusFilter = "all";
view.libraryGenreFilter = "all";
view.searchQuery = "";
// Full re-render — the toolbar search input needs to reset too.
view.renderView();
});
}
}