svm0n_datadeck/src/modals.ts
Cursor Agent 0aa57b978c fix(add-entry): exclude title/index column from categorical dropdown detection
Co-authored-by: SVM0N <SVM0N@users.noreply.github.com>
2026-07-06 07:19:14 +00:00

911 lines
42 KiB
TypeScript

import {
App,
Modal,
Component,
MarkdownRenderer,
Notice,
} from "obsidian";
import { CSVRow, FileConfig, ViewMode } from "./types";
import { showSelectPicker, titleCase, isMultiValueColName, looksCategorical } from "./utils";
import { suggestionsFor, isDateCol, ISO_DATE } from "./field-types";
// ─── Shared field input ─────────────────────────────────────────────────────
//
// Used by both the Add-entry modal and the inline (note-expander) editor so a
// given column type edits the same way everywhere. The name/value heuristics
// live in field-types.ts (pure, unit-tested).
/**
* Build the <input> for a non-notes, non-select cell:
* - date-like columns get a native yyyy-mm-dd picker — but only when the
* current value is empty or already clean ISO, so partial/blank values
* like "2022-06-??" keep a plain text field and are never clobbered
* - columns with known options get a non-strict <datalist> dropdown
* - everything else is a plain text input
* Empty values stay allowed throughout (intentional for undated trips).
*/
export function makeFieldInput(parent: HTMLElement, header: string, initial: string, cls: string): HTMLInputElement {
const useDate = isDateCol(header) && (initial === "" || ISO_DATE.test(initial));
const input = parent.createEl("input", { cls, type: useDate ? "date" : "text", value: initial });
if (!useDate) input.placeholder = header;
const sugg = suggestionsFor(header);
if (sugg) {
const id = `csv-dl-${header.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${Math.random().toString(36).slice(2, 7)}`;
const dl = parent.createEl("datalist");
dl.id = id;
sugg.forEach(opt => dl.createEl("option", { value: opt }));
input.setAttr("list", id);
}
return input;
}
// ─── Add Entry Modal ──────────────────────────────────────────────────────────
export class AddEntryModal extends Modal {
headers: string[];
isNotesCol: (h: string) => boolean;
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
// brand-new file offers them before any row exists to harvest values from.
optionPresets: Record<string, string[]>;
constructor(
app: App,
headers: string[],
isNotesCol: (h: string) => boolean,
isSelectCol: (h: string) => boolean,
getColumnValues: (h: string) => string[],
onSubmit: (row: CSVRow) => void,
optionPresets: Record<string, string[]> = {},
isBooleanCol: (h: string) => boolean = () => false
) {
super(app);
this.headers = headers;
this.isNotesCol = isNotesCol;
this.isSelectCol = isSelectCol;
this.getColumnValues = getColumnValues;
this.onSubmit = onSubmit;
this.optionPresets = optionPresets;
this.isBooleanCol = isBooleanCol;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("csv-add-modal");
contentEl.createEl("h2", { text: "Add new entry", cls: "csv-modal-title" });
const form = contentEl.createDiv({ cls: "csv-modal-form" });
const values: CSVRow = {};
this.headers.forEach(h => values[h] = "");
// Duplicate check for the title field — typing a title that's already in
// the file shows a non-blocking hint (the user may genuinely want a
// duplicate, e.g. a rewatch row, so submission stays allowed).
const titleCol = this.headers.find(h => ["title", "name"].includes(h.toLowerCase()));
const existingTitles = new Map<string, string>(); // lowercase → original casing
if (titleCol) {
this.getColumnValues(titleCol).forEach(v => {
const key = v.trim().toLowerCase();
if (key && !existingTitles.has(key)) existingTitles.set(key, v.trim());
});
}
this.headers.forEach(h => {
const row = form.createDiv({ cls: "csv-modal-row" });
// titleCase so the label reads "Author" / "Year" / "Notes" regardless of
// how the CSV happens to capitalise its headers (Apple-style row labels).
row.createEl("label", { text: titleCase(h), cls: "csv-modal-label" });
const presets = this.optionPresets[h] ?? [];
// The title/index column is always a free-text identifier — never a
// dropdown, whether that's inferred (few distinct values / presets) or
// configured (settings.selectColumns / a future per-file categorical
// list). It's excluded up front so none of the branches below can
// route it into a select.
const isTitleCol = h === titleCol;
// Single-value option columns (preset-backed or configured select) render
// as a native <select>. Crucially this keeps focus *inside* the modal —
// the body-appended showSelectPicker (used elsewhere) loses focus to the
// 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.
// 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 = !isTitleCol && !this.isSelectCol(h) && !isMultiValueColName(h)
&& !isDateCol(h) && looksCategorical(this.getColumnValues(h).length);
const useNativeSelect = !isTitleCol && !this.isNotesCol(h) && (presets.length > 0 || (this.isSelectCol(h) && !isMultiValueColName(h)) || autoCategorical);
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; });
} else if (useNativeSelect) {
// Options: presets first (canonical order), then any extra existing
// values from the file, then a "+ Custom…" escape hatch that reveals
// an inline text input — so the column is never a closed vocabulary.
const CUSTOM = "__custom__";
const merged: string[] = [];
[...presets, ...this.getColumnValues(h)].forEach(v => {
const t = (v ?? "").trim();
if (t && !merged.some(m => m.toLowerCase() === t.toLowerCase())) merged.push(t);
});
const sel = row.createEl("select", { cls: "csv-modal-select" });
sel.createEl("option", { text: "—", value: "" });
merged.forEach(v => sel.createEl("option", { text: v, value: v }));
sel.createEl("option", { text: "+ Custom…", value: CUSTOM });
const customInput = row.createEl("input", { cls: "csv-modal-input csv-modal-custom", type: "text", placeholder: `Custom ${titleCase(h).toLowerCase()}` });
customInput.hide();
customInput.addEventListener("input", () => { values[h] = customInput.value; });
customInput.addEventListener("keydown", e => { if (e.key === "Enter") submit(); });
sel.addEventListener("change", () => {
if (sel.value === CUSTOM) {
customInput.show();
customInput.focus();
values[h] = customInput.value;
} else {
customInput.hide();
values[h] = sel.value;
}
});
} else if (!isTitleCol && this.isSelectCol(h)) {
// Multi-value select (tags/genres/themes): chip + picker, which the
// picker's "+ Add" / existing-value clicks drive. Still works inside
// the modal because the user clicks options rather than typing into a
// focus-trapped search.
const chipWrap = row.createDiv({ cls: "csv-modal-select-wrap" });
const chip = chipWrap.createDiv({ cls: "csv-select-chip empty" });
chip.setText("— click to select —");
chip.addEventListener("click", e => {
e.stopPropagation();
showSelectPicker(chip, values[h], this.getColumnValues(h), (newVal) => {
values[h] = newVal;
chip.setText(newVal || "— click to select —");
chip.toggleClass("empty", !newVal);
}, contentEl, { multi: isMultiValueColName(h) });
});
} else {
const input = makeFieldInput(row, h, "", "csv-modal-input");
input.addEventListener("input", () => { values[h] = input.value; });
// Submit on Enter for non-textarea fields
input.addEventListener("keydown", e => { if (e.key === "Enter") submit(); });
if (h === titleCol && existingTitles.size) {
const hint = row.createDiv({ cls: "csv-modal-dup-hint" });
hint.hide();
input.addEventListener("input", () => {
const match = existingTitles.get(input.value.trim().toLowerCase());
if (match) {
hint.setText(`⚠ “${match}” is already in this file`);
hint.show();
} else {
hint.hide();
}
});
}
}
});
const btnRow = contentEl.createDiv({ cls: "csv-modal-btns" });
const cancelBtn = btnRow.createEl("button", { text: "Cancel", cls: "csv-modal-cancel" });
cancelBtn.addEventListener("click", () => this.close());
const submitBtn = btnRow.createEl("button", { text: "Add entry", cls: "csv-modal-submit" });
submitBtn.addEventListener("click", () => submit());
const submit = () => {
// Require at least one field filled
const hasAny = Object.values(values).some(v => v.trim());
if (!hasAny) { new Notice("Fill in at least one field."); return; }
this.onSubmit(values);
this.close();
};
}
onClose(): void {
this.contentEl.empty();
}
}
// ─── Note Expander Modal ──────────────────────────────────────────────────────
export class NoteExpanderModal extends Modal {
private row: CSVRow;
private notesCol: string;
private headers: string[];
private filePath: string;
private renderComponent: Component;
private isNotesCol: (h: string) => boolean;
private isSelectCol: (h: string) => boolean;
private getColumnValues: (h: string) => string[];
private onSave: (row: CSVRow) => void;
private onDelete?: () => void;
constructor(
app: App,
row: CSVRow,
notesCol: string,
headers: string[],
filePath: string,
isNotesCol: (h: string) => boolean,
isSelectCol: (h: string) => boolean,
getColumnValues: (h: string) => string[],
onSave: (row: CSVRow) => void,
onDelete?: () => void
) {
super(app);
// Work on a shallow copy so cancel doesn't mutate
this.row = { ...row };
this.notesCol = notesCol;
this.headers = headers;
this.filePath = filePath;
this.renderComponent = new Component();
this.isNotesCol = isNotesCol;
this.isSelectCol = isSelectCol;
this.getColumnValues = getColumnValues;
this.onSave = onSave;
this.onDelete = onDelete;
this.modalEl.addClass("csv-note-expander-modal");
}
// Bound handler so we can add/remove the same reference. Set in onOpen.
private vvHandler: (() => void) | null = null;
onOpen(): void {
this.renderComponent.load();
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("csv-note-expander");
// iOS keyboard handling. `dvh` should shrink the modal when the
// virtual keyboard appears, but iOS Obsidian's WKWebView doesn't
// always honour it. The visualViewport API is the reliable signal:
// it reports the actually-visible viewport (i.e. screen minus
// keyboard). Mirror it into a CSS variable on modalEl and let the
// stylesheet clamp max-height off of that. Px is the unit so the
// value is concrete; the clamp falls back to dvh/vh if the API
// isn't available.
const vv = (window as { visualViewport?: VisualViewport }).visualViewport;
if (vv) {
const update = () => {
// visualViewport.height = screen minus iOS keyboard when open.
// The CSS uses --csv-modal-vh to clamp the modal's max-height so
// the modal always fits above the keyboard. Horizontal centering
// is left to Obsidian's parent flex; the mobile media query
// top-anchors the modal via align-self + margin-top instead of
// overriding the transform (which would un-center it).
this.modalEl.style.setProperty("--csv-modal-vh", `${vv.height}px`);
};
update();
vv.addEventListener("resize", update);
vv.addEventListener("scroll", update);
this.vvHandler = update;
}
// ── Header ──────────────────────────────────────────────────────────────
const header = contentEl.createDiv({ cls: "csv-expander-header" });
header.createDiv({ cls: "csv-expander-title", text: this.row[this.headers.find(h => ["title","name","Title","Name"].includes(h)) ?? this.headers[0]] ?? "—" });
const headerBtns = header.createDiv({ cls: "csv-expander-header-btns" });
// ── Fields section (non-notes columns) ──────────────────────────────────
const fieldsEl = contentEl.createDiv({ cls: "csv-expander-fields" });
const titleKey = this.headers.find(h => ["title","name","Title","Name"].includes(h));
const authorKey = this.headers.find(h => ["author","Author","director","Director","artist","Artist","creator","Creator"].includes(h));
this.headers.forEach(h => {
if (this.isNotesCol(h)) return; // notes rendered separately below
const fieldRow = fieldsEl.createDiv({ cls: "csv-expander-field-row" });
// titleCase: Apple-style row labels, independent of CSV header casing.
fieldRow.createDiv({ cls: "csv-expander-field-label", text: titleCase(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. The title/index
// column is excluded — it's a free-text identifier, never a dropdown,
// even if it's short on distinct values or listed in selectColumns.
const selectLike = h !== titleKey && (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 => {
e.stopPropagation();
showSelectPicker(chip, this.row[h], this.getColumnValues(h), (newVal) => {
this.row[h] = newVal;
chip.setText(newVal || "—");
chip.toggleClass("empty", !newVal);
}, contentEl, { multi: isMultiValueColName(h) });
});
} else {
const val = fieldRow.createDiv({ cls: "csv-expander-field-value", text: this.row[h] || "—" });
val.addEventListener("click", () => {
val.empty();
const input = makeFieldInput(val, h, this.row[h] ?? "", "csv-inline-input");
input.focus(); if (input.type === "text") input.select();
const commit = () => { this.row[h] = input.value; val.empty(); val.setText(input.value || "—"); };
input.addEventListener("blur", commit);
input.addEventListener("keydown", e => { if (e.key === "Enter") input.blur(); if (e.key === "Escape") { val.empty(); val.setText(this.row[h] || "—"); } });
});
}
});
// ── Notes section ────────────────────────────────────────────────────────
// No "Edit" button: clicking the rendered area enters edit mode (same
// 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.
// 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.notesCol ? (this.row[this.notesCol] ?? "") : "";
let ta: HTMLTextAreaElement | null = null;
if (this.notesCol) {
const notesDivider = contentEl.createDiv({ cls: "csv-expander-divider" });
notesDivider.createDiv({ cls: "csv-expander-notes-label", text: this.notesCol });
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";
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]
// Delete sits far left so it can't be hit by reflex when reaching for Save.
const footer = contentEl.createDiv({ cls: "csv-expander-footer" });
if (this.onDelete) {
const titleVal = String(this.row[this.headers.find(h => ["title","name","Title","Name"].includes(h)) ?? this.headers[0]] ?? "").trim();
footer.createEl("button", { cls: "csv-expander-delete-btn", text: "Delete" })
.addEventListener("click", () => {
const label = titleVal || "this entry";
if (!window.confirm(`Delete "${label}"? This can't be undone.`)) return;
this.onDelete!();
this.close();
});
}
const rightBtns = footer.createDiv({ cls: "csv-expander-footer-right" });
rightBtns.createEl("button", { cls: "csv-modal-cancel", text: "Cancel" })
.addEventListener("click", () => this.close()); // close without saving
rightBtns.createEl("button", { cls: "csv-expander-save-btn", text: "Save & close" })
.addEventListener("click", () => {
// If still in edit mode (user clicked Save without blurring), grab
// the live textarea content; otherwise currentText is already fresh.
// 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();
});
}
onClose(): void {
const vv = (window as { visualViewport?: VisualViewport }).visualViewport;
if (vv && this.vvHandler) {
vv.removeEventListener("resize", this.vvHandler);
vv.removeEventListener("scroll", this.vvHandler);
}
this.vvHandler = null;
this.renderComponent.unload();
this.contentEl.empty();
}
}
// ─── Search Modal (mobile) ────────────────────────────────────────────────────
// On mobile, the toolbar 🔍 button opens this instead of expanding the search
// inline. The modal owns its own viewport — iOS keyboard layout quirks don't
// bleed into the kanban / table render underneath. Reuses the same
// visualViewport top-pinning pattern as NoteExpanderModal so the input
// sits above the keyboard.
export class SearchModal extends Modal {
private initialQuery: string;
private onInput: (query: string) => void;
private resultCount: () => { matched: number; total: number };
private getMatches: () => Array<{ title: string; subtitle?: string; row: CSVRow }>;
private onPick: (row: CSVRow) => void;
private vvHandler: (() => void) | null = null;
constructor(
app: App,
initialQuery: string,
onInput: (query: string) => void,
resultCount: () => { matched: number; total: number },
getMatches: () => Array<{ title: string; subtitle?: string; row: CSVRow }>,
onPick: (row: CSVRow) => void,
) {
super(app);
this.initialQuery = initialQuery;
this.onInput = onInput;
this.resultCount = resultCount;
this.getMatches = getMatches;
this.onPick = onPick;
this.modalEl.addClass("csv-search-modal");
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("csv-search-modal-content");
// Transparent backdrop. Obsidian's default modal-bg dims the page
// underneath to near-black, which hides the filter results — the
// user types but can't see the table updating. Tag the container so
// a CSS rule (`.mod-csv-search-bg .modal-bg`) zeroes out the dim.
// Also defensively clear the inline opacity/background in case the
// CSS rule loses the cascade to a later !important. Tap-outside still
// closes because the bg element retains its click handler.
this.containerEl.addClass("mod-csv-search-bg");
const clearBg = () => {
const bg = this.containerEl.querySelector<HTMLElement>(".modal-bg");
if (bg) {
bg.style.opacity = "0";
bg.style.background = "transparent";
}
};
clearBg();
// Obsidian sometimes restyles the bg after onOpen runs; redo it on
// the next frame and a tick later for good measure.
requestAnimationFrame(clearBg);
setTimeout(clearBg, 50);
// visualViewport pinning — same trick as NoteExpanderModal. Without
// it, Obsidian centers the modal in window.innerHeight and the iOS
// keyboard hides the bottom of it. We write visualViewport.height
// into --csv-modal-vh; the CSS uses align-self/margin-top on mobile
// to dock the modal at the top of the visible viewport.
const vv = (window as { visualViewport?: VisualViewport }).visualViewport;
if (vv) {
const update = () => {
this.modalEl.style.setProperty("--csv-modal-vh", `${vv.height}px`);
};
update();
vv.addEventListener("resize", update);
vv.addEventListener("scroll", update);
this.vvHandler = update;
}
const row = contentEl.createDiv({ cls: "csv-search-modal-row" });
const input = row.createEl("input", {
cls: "csv-search-modal-input",
type: "text",
placeholder: "Search…",
value: this.initialQuery,
attr: { inputmode: "search", enterkeyhint: "search", autocomplete: "off" },
});
const closeBtn = row.createEl("button", { cls: "csv-search-modal-close", text: "✕", title: "Close" });
closeBtn.addEventListener("click", () => this.close());
const countEl = contentEl.createDiv({ cls: "csv-search-modal-count" });
// Results preview list. iOS keyboard shrinks the underlying view to
// ~50px of content area, so the user can't see filter results on the
// page while typing. Render top matches inside the modal itself —
// tap a row to open its expander.
const listEl = contentEl.createDiv({ cls: "csv-search-modal-list" });
const PREVIEW_LIMIT = 40;
const refresh = () => {
const { matched, total } = this.resultCount();
countEl.setText(input.value.trim()
? `${matched} of ${total} entries match`
: `${total} entries — start typing to search`);
listEl.empty();
if (!input.value.trim()) return;
const matches = this.getMatches().slice(0, PREVIEW_LIMIT);
matches.forEach(({ title, subtitle, row }) => {
const item = listEl.createDiv({ cls: "csv-search-modal-item" });
item.createDiv({ cls: "csv-search-modal-item-title", text: title || "—" });
if (subtitle) item.createDiv({ cls: "csv-search-modal-item-sub", text: subtitle });
item.addEventListener("click", () => {
this.close();
this.onPick(row);
});
});
if (matched > PREVIEW_LIMIT) {
listEl.createDiv({
cls: "csv-search-modal-more",
text: `+ ${matched - PREVIEW_LIMIT} more — close to see all in the view`,
});
}
};
refresh();
// Debounced apply — matches the inline-search behaviour so big tables
// don't re-render on every keystroke.
let debounce: number | null = null;
input.addEventListener("input", () => {
if (debounce !== null) window.clearTimeout(debounce);
debounce = window.setTimeout(() => {
debounce = null;
this.onInput(input.value);
refresh();
}, 120);
});
// Pressing Enter / Return on the on-screen keyboard closes the modal
// so the user can see the filtered list. Filter stays applied.
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") this.close();
});
// Autofocus after one frame so iOS reliably opens the keyboard
// (focus called synchronously sometimes gets dropped on modal-open).
requestAnimationFrame(() => {
input.focus();
// Caret at the end of existing query so user can extend or backspace.
input.setSelectionRange(input.value.length, input.value.length);
});
}
onClose(): void {
const vv = (window as { visualViewport?: VisualViewport }).visualViewport;
if (vv && this.vvHandler) {
vv.removeEventListener("resize", this.vvHandler);
vv.removeEventListener("scroll", this.vvHandler);
}
this.vvHandler = null;
this.contentEl.empty();
}
}
// ─── File Config Modal ────────────────────────────────────────────────────────
// Per-file column mapping — which column is the kanban group, notes, status
export class FileConfigModal extends Modal {
headers: string[];
filePath: string;
current: FileConfig;
autoDetectedHabits: string[];
availableModes: { id: ViewMode; label: string }[];
onSave: (cfg: FileConfig) => void;
// Column-structure ops (add/remove the CSV column itself, not its role).
// Return a fresh headers/config snapshot so the modal can re-render in
// place after a mutation rather than requiring a close+reopen.
getHeaders: () => string[];
getFileCfg: () => FileConfig;
onAddColumn: (name: string) => string | null;
onRemoveColumn: (header: string) => void;
constructor(
app: App, headers: string[], filePath: string, current: FileConfig, autoDetectedHabits: string[],
availableModes: { id: ViewMode; label: string }[], onSave: (cfg: FileConfig) => void,
getHeaders: () => string[], getFileCfg: () => FileConfig,
onAddColumn: (name: string) => string | null, onRemoveColumn: (header: string) => void,
) {
super(app);
this.headers = headers;
this.filePath = filePath;
this.current = { ...current, habitColumns: current.habitColumns ? [...current.habitColumns] : undefined };
this.autoDetectedHabits = autoDetectedHabits;
this.availableModes = availableModes;
this.onSave = onSave;
this.getHeaders = getHeaders;
this.getFileCfg = getFileCfg;
this.onAddColumn = onAddColumn;
this.onRemoveColumn = onRemoveColumn;
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("csv-add-modal");
contentEl.createEl("h2", { text: "View settings for this file", cls: "csv-modal-title" });
contentEl.createEl("p", { text: "These settings apply only to this file and override the global defaults.", cls: "csv-modal-desc" });
const form = contentEl.createDiv({ cls: "csv-modal-form" });
const none = "— use global default —";
const opts = [none, ...this.headers];
// Column structure — add a new CSV column or delete an existing one.
// Re-fetches headers/config and calls onOpen() again after any mutation
// so every dropdown/checklist below reflects the new column set, and any
// per-file config that pointed at a just-deleted column shows cleared.
const refresh = () => {
this.headers = this.getHeaders();
const cfg = this.getFileCfg();
this.current = { ...cfg, habitColumns: cfg.habitColumns ? [...cfg.habitColumns] : undefined };
this.onOpen();
};
const colRow = form.createDiv({ cls: "csv-modal-row" });
colRow.createEl("label", { text: "Columns", cls: "csv-modal-label" });
const colList = colRow.createDiv({ cls: "csv-modal-column-list" });
this.headers.forEach(h => {
const chip = colList.createDiv({ cls: "csv-modal-column-chip" });
chip.createSpan({ text: h });
const rm = chip.createEl("button", {
cls: "csv-modal-column-remove", text: "✕",
attr: { title: `Remove "${h}" — deletes this column's data from every row` },
});
rm.addEventListener("click", () => {
if (rm.hasClass("confirm")) {
this.onRemoveColumn(h);
refresh();
return;
}
// Destructive — require a second click within a few seconds rather
// than a native confirm() dialog, matching the rest of the modal.
colList.querySelectorAll(".csv-modal-column-remove.confirm").forEach(el => { el.removeClass("confirm"); el.setText("✕"); });
rm.addClass("confirm");
rm.setText("Confirm?");
window.setTimeout(() => { if (rm.isConnected) { rm.removeClass("confirm"); rm.setText("✕"); } }, 3000);
});
});
const addColWrap = colRow.createDiv({ cls: "csv-modal-add-column" });
const addColInput = addColWrap.createEl("input", { cls: "csv-modal-input", type: "text", placeholder: "New column name" });
const addColBtn = addColWrap.createEl("button", { cls: "csv-modal-cancel", text: "+ Add column" });
const doAdd = () => {
const err = this.onAddColumn(addColInput.value);
if (err) { new Notice(err); return; }
refresh();
};
addColBtn.addEventListener("click", doAdd);
addColInput.addEventListener("keydown", e => { if (e.key === "Enter") { e.preventDefault(); doAdd(); } });
const makeDropdown = (label: string, currentVal: string | undefined, onChange: (v: string | undefined) => void) => {
const row = form.createDiv({ cls: "csv-modal-row" });
row.createEl("label", { text: label, cls: "csv-modal-label" });
const sel = row.createEl("select", { cls: "csv-modal-select" });
opts.forEach(o => {
const opt = sel.createEl("option", { text: o, value: o });
if ((currentVal ?? none) === o) opt.selected = true;
});
sel.addEventListener("change", () => onChange(sel.value === none ? undefined : sel.value));
};
makeDropdown("Category column (kanban grouping)", this.current.categoryColumn, v => { this.current.categoryColumn = v; });
makeDropdown("Status column (row subgroups)", this.current.statusColumn, v => { this.current.statusColumn = v; });
makeDropdown("Notes column", this.current.notesColumn, v => { this.current.notesColumn = v; });
// Habit columns (multi-select with checkboxes)
const habitRow = form.createDiv({ cls: "csv-modal-row" });
habitRow.createEl("label", { text: "Habit columns (dashboard)", cls: "csv-modal-label" });
const habitDesc = habitRow.createEl("p", { cls: "csv-modal-hint", text: "Select columns to track as habits. Auto-detected columns with binary values (0/1) are pre-selected." });
const habitGrid = habitRow.createDiv({ cls: "csv-modal-checkbox-grid" });
// Determine which columns are selected (use config if set, else auto-detected)
const selectedHabits = new Set(this.current.habitColumns ?? this.autoDetectedHabits);
this.headers.forEach(h => {
const label = habitGrid.createEl("label", { cls: "csv-modal-checkbox-label" });
const checkbox = label.createEl("input", { type: "checkbox" });
checkbox.checked = selectedHabits.has(h);
if (this.autoDetectedHabits.includes(h) && !this.current.habitColumns) {
label.addClass("auto-detected");
}
label.createSpan({ text: h });
checkbox.addEventListener("change", () => {
if (!this.current.habitColumns) {
this.current.habitColumns = [...this.autoDetectedHabits];
}
if (checkbox.checked) {
if (!this.current.habitColumns.includes(h)) {
this.current.habitColumns.push(h);
}
} else {
this.current.habitColumns = this.current.habitColumns.filter(c => c !== h);
}
});
});
// Card fields — which columns to surface on Library/Kanban cards.
// If never set, defaults to auto-detected (author/year/rating/theme).
// Once the user touches the checkboxes, an explicit list is stored.
const cardRow = form.createDiv({ cls: "csv-modal-row" });
cardRow.createEl("label", { text: "Card fields (Library / Kanban)", cls: "csv-modal-label" });
cardRow.createEl("p", { cls: "csv-modal-hint", text: "Columns shown under each card title. Rating renders as stars, theme/tag columns as pills. Leave all unchecked for title-only cards." });
const cardGrid = cardRow.createDiv({ cls: "csv-modal-checkbox-grid" });
// Auto-detected defaults — used when cardFields is undefined.
const autoDetect = (candidates: string[]) =>
this.headers.find(h => candidates.some(c => c.toLowerCase() === h.toLowerCase()));
const autoFields = [
autoDetect(["Author","Authors","Director","Artist","Creator","By"]),
autoDetect(["Year","Date","Released"]),
autoDetect(["Rating","Score","Score /5","Stars"]),
autoDetect(["Theme","Tags","Tag","Mood"]),
].filter((c): c is string => !!c);
const selectedCard = new Set(this.current.cardFields ?? autoFields);
const isCustom = !!this.current.cardFields;
this.headers.forEach(h => {
const label = cardGrid.createEl("label", { cls: "csv-modal-checkbox-label" });
const checkbox = label.createEl("input", { type: "checkbox" });
checkbox.checked = selectedCard.has(h);
if (autoFields.includes(h) && !isCustom) label.addClass("auto-detected");
label.createSpan({ text: h });
checkbox.addEventListener("change", () => {
// First touch promotes auto-detected defaults into an explicit list.
if (!this.current.cardFields) this.current.cardFields = [...autoFields];
if (checkbox.checked) {
if (!this.current.cardFields.includes(h)) this.current.cardFields.push(h);
} else {
this.current.cardFields = this.current.cardFields.filter(c => c !== h);
}
});
});
// Anki card front — which column becomes the front of each card on
// "Sync to Anki". Unset = the title/primary field; every other non-empty
// column is joined onto the back. Its own "primary field" sentinel rather
// than the shared global-default one, since there's no global Anki front.
const ankiNone = "— title / primary field —";
const ankiRow = form.createDiv({ cls: "csv-modal-row" });
ankiRow.createEl("label", { text: "Anki card front", cls: "csv-modal-label" });
ankiRow.createEl("p", { cls: "csv-modal-hint", text: "Column used as the front of each card when syncing to Anki. Other columns become the back." });
const ankiSel = ankiRow.createEl("select", { cls: "csv-modal-select" });
[ankiNone, ...this.headers].forEach(o => {
const opt = ankiSel.createEl("option", { text: o, value: o });
if ((this.current.ankiFrontCol ?? ankiNone) === o) opt.selected = true;
});
ankiSel.addEventListener("change", () => { this.current.ankiFrontCol = ankiSel.value === ankiNone ? undefined : ankiSel.value; });
// Default mode for this file. The list comes from availableModes (same
// source as the toolbar dropdown), so it offers exactly the modes this
// file's columns can render — Travel appears for travel logs, Stats only
// when there's something chartable, etc. The old hardcoded list both
// omitted Travel and offered modes the file couldn't actually show.
const modeRow = form.createDiv({ cls: "csv-modal-row" });
modeRow.createEl("label", { text: "Default view", cls: "csv-modal-label" });
const modeSel = modeRow.createEl("select", { cls: "csv-modal-select" });
const modeOptions: [string, string][] = [
["— use global default —", ""],
...this.availableModes.map(m => [m.label, m.id] as [string, string]),
];
// A previously saved mode the file can no longer render (columns changed)
// still shows up, flagged — so the user can see and clear the stale pick.
const saved = this.current.defaultMode ?? "";
if (saved && !this.availableModes.some(m => m.id === saved)) {
modeOptions.push([`${saved} (no longer available)`, saved]);
}
modeOptions.forEach(([label, val]) => {
const opt = modeSel.createEl("option", { text: label, value: val });
if (saved === val) opt.selected = true;
});
modeSel.addEventListener("change", () => { this.current.defaultMode = modeSel.value ? modeSel.value as ViewMode : undefined; });
const btnRow = contentEl.createDiv({ cls: "csv-modal-btns" });
btnRow.createEl("button", { text: "Cancel", cls: "csv-modal-cancel" }).addEventListener("click", () => this.close());
btnRow.createEl("button", { text: "Save", cls: "csv-modal-submit" }).addEventListener("click", () => {
this.onSave(this.current);
this.close();
});
}
onClose(): void { this.contentEl.empty(); }
}
// ─── Prompt modal ───────────────────────────────────────────────────────────
//
// A minimal single-field text prompt (title + input + Cancel/OK). Used by the
// "create … file" palette commands to ask for the new file's name. onSubmit
// fires only on OK/Enter with a non-empty value, so callers never have to
// guard against blank names.
export class PromptModal extends Modal {
constructor(
app: App,
private title: string,
private initial: string,
private placeholder: string,
private onSubmit: (value: string) => void
) {
super(app);
}
onOpen(): void {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("csv-add-modal");
contentEl.createEl("h2", { text: this.title, cls: "csv-modal-title" });
const input = contentEl.createEl("input", { cls: "csv-modal-field", type: "text" });
input.value = this.initial;
input.placeholder = this.placeholder;
// Select the basename so the user can type over the default immediately.
window.setTimeout(() => { input.focus(); input.select(); }, 0);
const submit = () => {
const value = input.value.trim();
if (!value) return;
this.onSubmit(value);
this.close();
};
input.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); submit(); }
});
const btnRow = contentEl.createDiv({ cls: "csv-modal-btns" });
btnRow.createEl("button", { text: "Cancel", cls: "csv-modal-cancel" }).addEventListener("click", () => this.close());
btnRow.createEl("button", { text: "Create", cls: "csv-modal-submit" }).addEventListener("click", submit);
}
onClose(): void { this.contentEl.empty(); }
}