feat(config): surface auto-detected roles in the ⚙ Config modal

The per-column Role dropdown only ever showed an explicit fileCfg
override — a column literally named 'Status' (or Category/Notes/Image/
Anki-front by their own name lists) already drives getStatusCol() etc.
at render time by name, but the modal showed '— no special role —' for
it with nothing to indicate that. This was the exact confusion behind
a user question: 'what corresponds to the checkmark in the file?' —
the Status role does, but for a conventionally-named column there was
no visual confirmation it was already active.

- Extracted the Category/Status/Notes column name-alias lists (already
  duplicated between main.ts and inline-view.ts) into shared constants
  in src/utils.ts, and split ankiFrontCol's fallback chain out into an
  exported autoAnkiFrontCol() in src/view/anki.ts. Both were already
  pure name/position lookups; this just makes them independently
  callable without an explicit override applied.
- FileConfigModal now takes an AutoDetectedRoles snapshot (computed
  once in toolbar.ts's openColumns(), the same lists/functions the
  views themselves use) and roleOf() shows the resolved role plus an
  'auto (by name)' badge whenever nothing is explicitly configured for
  that slot — exactly mirroring the fact that explicit overrides always
  win outright over name detection at runtime (never blended).

Tests: two new FileConfigModal cases — a name-matched column shows its
role with the auto badge and no saved config, and an explicit override
on a different column suppresses the badge everywhere for that role,
matching what getStatusCol()/etc. actually resolve.

Co-authored-by: SVM0N <SVM0N@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-07-06 08:47:14 +00:00
parent 53ca235bc2
commit e260a4ed00
9 changed files with 182 additions and 85 deletions

40
main.js

File diff suppressed because one or more lines are too long

33
main.ts
View file

@ -19,7 +19,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, IMAGE_COL_ALIASES, looksBoolean, looksCategorical } from "./src/utils";
import { sanitizeFilename, tagify, titleCase, formatRatingForDisplay, showSelectPicker, parseCSV, migrateFileConfigKey, sortRowsByColumn, isMultiValueColName, IMAGE_COL_ALIASES, CATEGORY_COL_ALIASES, STATUS_COL_ALIASES, NOTES_COL_ALIASES, looksBoolean, looksCategorical } from "./src/utils";
import { isDateCol } from "./src/field-types";
import { AddEntryModal, NoteExpanderModal, FileConfigModal, SearchModal, PromptModal } from "./src/modals";
import { renderTravel } from "./src/travel-view";
@ -233,15 +233,7 @@ export class CardView extends FileView {
// 1. Per-file override
if (this.fileCfg.notesColumn) return this.fileCfg.notesColumn;
// 2. Fallback chain
return this.resolveCol([
"Notes","notes","Note","note",
"Summary","summary",
"Review","review",
"Quote","quote","Quotes","quotes",
"Comment","comment","Comments","comments",
"Description","description",
"Annotation","annotation",
]);
return this.resolveCol(NOTES_COL_ALIASES);
}
isNotesCol(h: string): boolean {
@ -283,31 +275,14 @@ export class CardView extends FileView {
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",
]);
return this.resolveCol(STATUS_COL_ALIASES);
}
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",
]);
return this.resolveCol(CATEGORY_COL_ALIASES);
}
titleKey(): string | undefined {

View file

@ -32,7 +32,7 @@ import {
import Papa from "papaparse";
import type { CardView } from "../main";
import { CSVRow, ViewMode, FileConfig, CardViewSettings } from "./types";
import { parseCSV, resolvePath, sanitizeFilename, showSelectPicker, IMAGE_COL_ALIASES, looksBoolean, looksCategorical, isMultiValueColName } from "./utils";
import { parseCSV, resolvePath, sanitizeFilename, showSelectPicker, IMAGE_COL_ALIASES, CATEGORY_COL_ALIASES, STATUS_COL_ALIASES, NOTES_COL_ALIASES, looksBoolean, looksCategorical, isMultiValueColName } from "./utils";
import { isDateCol } from "./field-types";
import { AddEntryModal, NoteExpanderModal } from "./modals";
import { renderTable } from "./view/table";
@ -257,11 +257,7 @@ export class InlineCardHost extends MarkdownRenderChild {
}
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",
]);
return this.resolveCol(NOTES_COL_ALIASES);
}
isNotesCol(h: string): boolean {
const notesCol = this.getNotesCol();
@ -284,20 +280,13 @@ export class InlineCardHost extends MarkdownRenderChild {
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",
]);
return this.resolveCol(STATUS_COL_ALIASES);
}
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",
]);
return this.resolveCol(CATEGORY_COL_ALIASES);
}
titleKey(): string | undefined {
return this.resolveCol(["Title", "title", "Name", "name"]) ?? undefined;

View file

@ -648,12 +648,26 @@ export class SearchModal extends Modal {
// ─── File Config Modal ────────────────────────────────────────────────────────
// Per-file column mapping — which column is the kanban group, notes, status
// What each of the 5 exclusive per-column roles would resolve to with no
// explicit fileCfg override — i.e. what getCategoryCol/getStatusCol/
// getNotesCol/getImageCol/ankiFrontCol already do by name/position at
// render time. Lets the Config modal show "(auto)" on whichever column is
// already fulfilling a role, instead of looking like no column does.
export interface AutoDetectedRoles {
category: string | null;
status: string | null;
notes: string | null;
image: string | null;
anki: string | null;
}
export class FileConfigModal extends Modal {
headers: string[];
filePath: string;
current: FileConfig;
autoDetectedHabits: string[];
autoDetectedCategorical: string[];
autoDetectedRoles: AutoDetectedRoles;
availableModes: { id: ViewMode; label: string }[];
onSave: (cfg: FileConfig) => void;
// Column-structure ops (add/remove the CSV column itself, not its role).
@ -666,7 +680,7 @@ export class FileConfigModal extends Modal {
constructor(
app: App, headers: string[], filePath: string, current: FileConfig, autoDetectedHabits: string[],
autoDetectedCategorical: string[],
autoDetectedCategorical: string[], autoDetectedRoles: AutoDetectedRoles,
availableModes: { id: ViewMode; label: string }[], onSave: (cfg: FileConfig) => void,
getHeaders: () => string[], getFileCfg: () => FileConfig,
onAddColumn: (name: string) => string | null, onRemoveColumn: (header: string) => void,
@ -681,6 +695,7 @@ export class FileConfigModal extends Modal {
};
this.autoDetectedHabits = autoDetectedHabits;
this.autoDetectedCategorical = autoDetectedCategorical;
this.autoDetectedRoles = autoDetectedRoles;
this.availableModes = availableModes;
this.onSave = onSave;
this.getHeaders = getHeaders;
@ -730,7 +745,7 @@ export class FileConfigModal extends Modal {
colSection.createEl("label", { text: "Columns", cls: "csv-modal-label" });
colSection.createEl("p", {
cls: "csv-modal-hint",
text: "Role is exclusive — picking it for one column clears it from any other. The three toggles can be combined freely.",
text: "Role is exclusive — picking it for one column clears it from any other. \"auto (by name)\" means it's already playing that role because of its column name, with nothing saved here yet. The three toggles can be combined freely.",
});
type Role = "category" | "status" | "notes" | "image" | "anki" | "";
@ -742,13 +757,28 @@ export class FileConfigModal extends Modal {
{ value: "image", label: "Image (card / kanban thumbnail)" },
{ value: "anki", label: "Anki card front" },
];
const roleOf = (h: string): Role => {
if (this.current.categoryColumn === h) return "category";
if (this.current.statusColumn === h) return "status";
if (this.current.notesColumn === h) return "notes";
if (this.current.imageColumn === h) return "image";
if (this.current.ankiFrontCol === h) return "anki";
return "";
// Explicit fileCfg fields always win outright over name-based detection —
// mirrors getCategoryCol/getStatusCol/getNotesCol/getImageCol/ankiFrontCol,
// which never blend an auto guess in once a role has an override, even if
// that override points at a column that no longer looks like a great fit.
// So `auto: true` only ever appears when the role's field is fully unset.
const roleOf = (h: string): { role: Role; auto: boolean } => {
if (this.current.categoryColumn !== undefined) { if (this.current.categoryColumn === h) return { role: "category", auto: false }; }
else if (this.autoDetectedRoles.category === h) return { role: "category", auto: true };
if (this.current.statusColumn !== undefined) { if (this.current.statusColumn === h) return { role: "status", auto: false }; }
else if (this.autoDetectedRoles.status === h) return { role: "status", auto: true };
if (this.current.notesColumn !== undefined) { if (this.current.notesColumn === h) return { role: "notes", auto: false }; }
else if (this.autoDetectedRoles.notes === h) return { role: "notes", auto: true };
if (this.current.imageColumn !== undefined) { if (this.current.imageColumn === h) return { role: "image", auto: false }; }
else if (this.autoDetectedRoles.image === h) return { role: "image", auto: true };
if (this.current.ankiFrontCol !== undefined) { if (this.current.ankiFrontCol === h) return { role: "anki", auto: false }; }
else if (this.autoDetectedRoles.anki === h) return { role: "anki", auto: true };
return { role: "", auto: false };
};
// Only clears the role(s) *this* column currently holds — reassigning a
// role to a different column evicts its previous holder for free, since
@ -813,11 +843,20 @@ export class FileConfigModal extends Modal {
const controls = row.createDiv({ cls: "csv-modal-colcfg-controls" });
const roleSel = controls.createEl("select", { cls: "csv-modal-select csv-modal-colcfg-role" });
const currentRole = roleOf(h);
// roleOf already reflects an auto-detected role in `role` (not just
// `auto`) — e.g. a column literally named "Status" shows "Status"
// selected here with no config saved at all, matching what
// getStatusCol() already resolves by name at render time. The badge
// is just there to distinguish "picked by name" from "picked by you".
const { role: currentRole, auto: isAutoRole } = roleOf(h);
ROLE_OPTIONS.forEach(o => {
const opt = roleSel.createEl("option", { text: o.label, value: o.value });
if (o.value === currentRole) opt.selected = true;
});
if (isAutoRole) {
roleSel.addClass("auto-detected");
controls.createSpan({ cls: "csv-modal-colcfg-auto-badge", text: "auto (by name)" });
}
roleSel.addEventListener("change", () => {
setRole(h, roleSel.value as Role);
renderRows();

View file

@ -6,6 +6,39 @@ import { CSVRow } from "./types";
// 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"];
// Column-name aliases for the three other name-detected roles (see
// getCategoryCol/getStatusCol/getNotesCol). Shared between main.ts,
// inline-view.ts, and the ⚙ Config modal's auto-detected role indicator, so
// the modal's "this is auto-detected" badge always agrees with what the
// views actually resolve at render time.
export const CATEGORY_COL_ALIASES = [
"Category","category","Categories","categories",
"Genre","genre","Genres","genres",
"Type","type","Types","types",
"Tag","tag","Tags","tags",
"Topic","topic","Topics","topics",
"Subject","subject",
"Section","section",
];
export const STATUS_COL_ALIASES = [
"Status","status",
"State","state",
"Progress","progress",
"Stage","stage",
"Read","read",
"Watched","watched","Seen","seen",
"Done","done","Completed","completed",
];
export const NOTES_COL_ALIASES = [
"Notes","notes","Note","note",
"Summary","summary",
"Review","review",
"Quote","quote","Quotes","quotes",
"Comment","comment","Comments","comments",
"Description","description",
"Annotation","annotation",
];
/**
* 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]]`,

View file

@ -38,20 +38,29 @@ async function ankiInvoke(action: string, params: Record<string, unknown>): Prom
}
/**
* Resolve the column used as the Anki card front. Honours the per-file
* `ankiFrontCol`; otherwise falls back to the title/primary field, then to a
* content-bearing column (so a quotes file fronts on Quote, not its first
* column Author), then to the first column.
* What ankiFrontCol would resolve to with no per-file override the
* title/primary field, then a content-bearing column (so a quotes file
* fronts on Quote, not its first column Author), then the first column.
* Exposed separately so the Config modal can show which column is
* *already* the front by name/position, without an explicit `ankiFrontCol`.
*/
export function ankiFrontCol(view: CardView): string | null {
const configured = view.fileCfg.ankiFrontCol;
if (configured && view.headers.includes(configured)) return configured;
export function autoAnkiFrontCol(view: CardView): string | null {
return view.titleKey()
?? view.resolveCol(["Quote", "Headline", "Phrase", "Term", "Word", "Question", "Front", "Name", "Title"])
?? view.headers[0]
?? null;
}
/**
* Resolve the column used as the Anki card front. Honours the per-file
* `ankiFrontCol`; otherwise falls back to autoAnkiFrontCol.
*/
export function ankiFrontCol(view: CardView): string | null {
const configured = view.fileCfg.ankiFrontCol;
if (configured && view.headers.includes(configured)) return configured;
return autoAnkiFrontCol(view);
}
// HTML-escape a cell so quotes/dictionary entries with <, >, & render as text
// in Anki rather than as broken markup.
function esc(s: string): string {

View file

@ -6,12 +6,13 @@
import { Menu, Notice } from "obsidian";
import type { CardView } from "../../main";
import { ViewMode } from "../types";
import { FileConfigModal } from "../modals";
import { FileConfigModal, AutoDetectedRoles } from "../modals";
import { generateMobileFiles } from "./mobile";
import { syncToAnki } from "./anki";
import { syncToAnki, autoAnkiFrontCol } from "./anki";
import { hasStatsColumns } from "./stats";
import { hasTaskColumns } from "./tasks";
import { effectiveGroupCol } from "./kanban";
import { CATEGORY_COL_ALIASES, STATUS_COL_ALIASES, NOTES_COL_ALIASES, IMAGE_COL_ALIASES } from "../utils";
declare const __BUILD_TIME__: string;
@ -197,9 +198,20 @@ export function renderToolbar(view: CardView, root: HTMLElement): void {
// Handlers are defined once and reused by both surfaces so there's a
// single place to maintain behaviour.
const openColumns = () => {
// What each exclusive role would resolve to with no fileCfg override,
// purely by column name/position — same lists/logic getCategoryCol etc.
// already use at render time — so the modal's "auto (by name)" badge
// always agrees with what's actually driving the view right now.
const autoDetectedRoles: AutoDetectedRoles = {
category: view.resolveCol(CATEGORY_COL_ALIASES),
status: view.resolveCol(STATUS_COL_ALIASES),
notes: view.resolveCol(NOTES_COL_ALIASES),
image: view.resolveCol(IMAGE_COL_ALIASES),
anki: autoAnkiFrontCol(view),
};
new FileConfigModal(
view.app, view.headers, view.file?.path ?? "", view.fileCfg, view.autoDetectBooleanColumns(),
view.autoDetectCategoricalColumns(), availableModes(view),
view.autoDetectCategoricalColumns(), autoDetectedRoles, availableModes(view),
(cfg) => {
view.saveFileCfg(cfg);
if (cfg.defaultMode) view.mode = cfg.defaultMode;

View file

@ -2032,6 +2032,18 @@ td .csv-select-chip {
width: auto;
}
.csv-modal-colcfg-role.auto-detected {
border-color: rgba(52, 168, 83, 0.4);
background: rgba(52, 168, 83, 0.08);
}
.csv-modal-colcfg-auto-badge {
font-size: 11px;
font-style: italic;
color: var(--color-green, #34a853);
flex: 0 0 auto;
}
.csv-modal-colcfg-toggle {
padding: 4px 8px;
flex: 0 0 auto;

View file

@ -828,9 +828,10 @@ await test("note-expander: an explicit isCategoricalCol override wins over auto-
const { FileConfigModal } = await load("./src/modals.ts");
function openFileConfigModal(headers, current, autoDetectedCategorical, overrides = {}) {
const autoDetectedRoles = { category: null, status: null, notes: null, image: null, anki: null, ...overrides.autoDetectedRoles };
const modal = new FileConfigModal(
new StubApp(), headers, "test.csv", current, overrides.autoDetectedHabits ?? [],
autoDetectedCategorical, overrides.availableModes ?? [],
autoDetectedCategorical, autoDetectedRoles, overrides.availableModes ?? [],
overrides.onSave ?? (() => {}),
overrides.getHeaders ?? (() => headers),
overrides.getFileCfg ?? (() => current),
@ -921,6 +922,33 @@ await test("FileConfigModal: role/toggle edits don't touch disk — only add/rem
assert(getFileCfgCalls === 0, "editing roles/toggles is a pending, in-memory draft until Save — it must not refetch from disk");
});
await test("FileConfigModal: a name-detected column shows its role with an auto badge, no config needed", async () => {
// Mirrors a real project dash file: nothing in fileCfg, but "Status" already
// resolves by name at render time (getStatusCol) — the row should show
// that, not "— no special role —", which is what prompted this feature.
const headers = ["Title", "Type", "Project", "Status"];
const modal = openFileConfigModal(headers, {}, [], { autoDetectedRoles: { status: "Status" } });
const sel = roleSelectFor(modal, "Status");
assert(sel.value === "status", "Status column shows the Status role even with nothing saved");
assert(sel.hasClass("auto-detected"), "auto-detected role select gets the auto-detected styling");
assert(colConfigRowFor(modal, "Status").textContent.includes("auto (by name)"), "row surfaces the auto badge text");
const typeSel = roleSelectFor(modal, "Type");
assert(typeSel.value === "", "a column with no matching alias and no config shows no role");
assert(!typeSel.hasClass("auto-detected"), "no badge when there's nothing auto-detected for this column");
});
await test("FileConfigModal: an explicit override elsewhere suppresses the auto badge, matching runtime resolution", async () => {
// getStatusCol() never blends a name-based guess back in once statusColumn
// is set — even to a different column — so neither the auto pick nor the
// explicit pick should show "auto" once an override exists for the role.
const headers = ["Title", "Status", "Progress"];
const modal = openFileConfigModal(headers, { statusColumn: "Progress" }, [], { autoDetectedRoles: { status: "Status" } });
assert(roleSelectFor(modal, "Progress").value === "status", "the explicit override holds the Status role");
assert(!roleSelectFor(modal, "Progress").hasClass("auto-detected"), "explicit picks aren't flagged as auto");
assert(roleSelectFor(modal, "Status").value === "", "the name-matched column no longer shows the role once something else is configured");
});
// ── Multi-select picker ──────────────────────────────────────────────────────
const { showSelectPicker, isMultiValueColName } = await load("./src/utils.ts");