Something went wrong. Try again.
Fork of github.com/ccmdi/propsec
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683import { App, Modal, Notice, setIcon, Setting, TFile, TFolder, AbstractInputSuggest } from "obsidian";import { SchemaField, SchemaMapping, CustomType, PropertyCondition,} from "../types";import { extractSchemaFromTemplate, getAllFieldTypes } from "../schema/extractor";import { getOperatorDisplayName, getOperatorsForPropertyType, PROPERTY_OPERATORS, PropertyOperator } from "../operators";import { validateQuery } from "../query/matcher";import { FieldEditorModal } from "./fieldEditorModal";
export interface SchemaEditorResult { saved: boolean; mapping: SchemaMapping;}
/** * Modal for editing a schema mapping */export class SchemaEditorModal extends FieldEditorModal { private mapping: SchemaMapping; private onSave: (mapping: SchemaMapping) => void; private templatesFolder: string; private customTypes: CustomType[]; private enablePropertySuggestions: boolean;
constructor( app: App, mapping: SchemaMapping, templatesFolder: string, customTypes: CustomType[], onSave: (mapping: SchemaMapping) => void, enablePropertySuggestions: boolean = true ) { super(app); this.mapping = JSON.parse(JSON.stringify(mapping)) as SchemaMapping; this.templatesFolder = templatesFolder; this.customTypes = customTypes; this.onSave = onSave; this.enablePropertySuggestions = enablePropertySuggestions; }
// ========== Abstract Method Implementations ==========
protected getFields(): SchemaField[] { return this.mapping.fields; }
protected setFields(fields: SchemaField[]): void { this.mapping.fields = fields; }
protected getAvailableTypes(): string[] { const primitives = getAllFieldTypes(); const customTypeNames = this.customTypes.map(t => t.name); return [...primitives, ...customTypeNames]; }
protected onFieldDeleted(index: number): void { this.mapping.fields.splice(index, 1); }
protected onAddField(): void { this.mapping.fields.push({ name: "", type: "string", required: true, }); this.doRenderFields(); }
// ========== Override for Property Suggestions ==========
private doRenderFields(): void { if (!this.fieldsContainer) return;
this.removeConstraintsSection(); this.expandedFields.clear(); this.fieldsContainer.empty();
const fields = this.getFields(); if (fields.length === 0) { this.fieldsContainer.createEl("p", { text: "No fields defined. Add fields or import from a template.", cls: "propsec-no-fields", }); return; }
fields.forEach((field, index) => { this.renderFieldCard( this.fieldsContainer!, field, index, this.enablePropertySuggestions ? (input) => this.attachPropertySuggest(input) : undefined ); }); }
private attachPropertySuggest(input: HTMLInputElement): void { const knownProperties = Object.keys(this.app.metadataTypeManager.properties); new PropertySuggest( this.app, input, knownProperties, (prop) => this.getPropertyType(prop), () => {}, (prop) => this.getPropertyDisplayName(prop) ); }
// ========== Override renderConstraints to Add Conditions ==========
protected override renderConstraints(container: HTMLElement, field: SchemaField): void { // Add condition section before type-specific constraints this.renderConditionSection(container, field); // Then render type-specific constraints from base class super.renderConstraints(container, field); }
// ========== Modal Lifecycle ==========
onOpen(): void { const { contentEl } = this;
contentEl.addClass("propsec-schema-editor");
// Header contentEl.createEl("h2", { text: `Edit Schema: ${this.mapping.name}` });
// Name field new Setting(contentEl) .setName("Name") .setDesc("A friendly name for this schema") .addText((text) => text .setValue(this.mapping.name) .onChange((value) => { this.mapping.name = value; }) );
// Query field with validation const querySetting = new Setting(contentEl) .setName("Query") .setDesc("Match files by folder or tag. Examples: folder, folder/*, #tag, folder/* or #tag");
const queryErrorEl = contentEl.createDiv({ cls: "propsec-query-error" });
querySetting.addText((text) => text .setPlaceholder("e.g., Folder/* or #tag") .setValue(this.mapping.query || "") .onChange((value) => { this.mapping.query = value; // Validate and show/hide error if (value.trim()) { const result = validateQuery(value); if (!result.valid) { queryErrorEl.setText(result.error || "Invalid query"); queryErrorEl.removeClass("propsec-hidden"); text.inputEl.addClass("propsec-input-error"); } else { queryErrorEl.addClass("propsec-hidden"); text.inputEl.removeClass("propsec-input-error"); } } else { queryErrorEl.addClass("propsec-hidden"); text.inputEl.removeClass("propsec-input-error"); } }) );
// Property Filter section this.renderPropertyFilterSection(contentEl);
// Separator contentEl.createEl("hr");
// Fields section header const fieldsHeader = contentEl.createDiv({ cls: "propsec-fields-header", }); fieldsHeader.createEl("h3", { text: "Fields" });
// Fields container this.fieldsContainer = contentEl.createDiv({ cls: "propsec-fields-container", });
this.setupScrollHandler(); this.doRenderFields();
// Add field button and import button const buttonsRow = contentEl.createDiv({ cls: "propsec-buttons-row", });
//eslint-disable-next-line obsidianmd/ui/sentence-case const addFieldBtn = buttonsRow.createEl("button", { text: "+ Add Field" }); addFieldBtn.addEventListener("click", () => this.onAddField());
const importBtn = buttonsRow.createEl("button", { //eslint-disable-next-line obsidianmd/ui/sentence-case text: "Import from Template...", }); importBtn.addEventListener("click", () => this.showTemplateSelector());
// Separator contentEl.createEl("hr");
// Save/Cancel buttons const footerButtons = contentEl.createDiv({ cls: "propsec-footer-buttons", });
const cancelBtn = footerButtons.createEl("button", { text: "Cancel" }); cancelBtn.addEventListener("click", () => this.close());
const saveBtn = footerButtons.createEl("button", { text: "Save", cls: "mod-cta", }); saveBtn.addEventListener("click", () => { this.mapping.fields = this.mapping.fields.filter( (f) => f.name.trim() !== "" ); this.onSave(this.mapping); this.close(); }); }
onClose(): void { this.cleanupScrollHandler(); const { contentEl } = this; contentEl.empty(); }
// ========== Field Conditions (Schema-specific) ==========
private renderConditionSection(container: HTMLElement, field: SchemaField): void { const section = container.createDiv({ cls: "propsec-condition-section" }); this.renderConditionSectionContent(section, field); }
private renderConditionSectionContent(section: HTMLElement, field: SchemaField): void { section.empty();
const header = section.createDiv({ cls: "propsec-condition-header" }); header.createEl("span", { text: "Conditions", cls: "propsec-constraints-title" });
const addBtn = header.createEl("button", { cls: "propsec-add-condition-btn", attr: { title: "Add condition" } }); setIcon(addBtn, "plus"); addBtn.addEventListener("click", () => { if (!field.conditions) field.conditions = []; field.conditions.push({ field: "", operator: "equals", value: "" }); this.renderConditionSectionContent(section, field); });
if (field.conditions && field.conditions.length > 0) { section.createEl("div", { text: "Only validate when all conditions match:", cls: "propsec-condition-desc" });
const list = section.createDiv({ cls: "propsec-conditions-list" }); for (let i = 0; i < field.conditions.length; i++) { this.renderFieldConditionRow(list, field, i, section); } } }
private renderFieldConditionRow(container: HTMLElement, field: SchemaField, index: number, section: HTMLElement): void { const condition = field.conditions![index]; const row = container.createDiv({ cls: "propsec-field-condition-row" });
// Field input const fieldInput = row.createEl("input", { type: "text", placeholder: "field", cls: "propsec-condition-field", }); fieldInput.value = condition.field;
if (this.enablePropertySuggestions) { this.attachPropertySuggest(fieldInput); }
fieldInput.addEventListener("input", (e) => { condition.field = (e.target as HTMLInputElement).value; });
// Operator select const operatorSelect = row.createEl("select", { cls: "propsec-condition-operator" }); for (const op of PROPERTY_OPERATORS) { const option = operatorSelect.createEl("option", { value: op, text: getOperatorDisplayName(op) }); if (op === condition.operator) option.selected = true; } operatorSelect.addEventListener("change", (e) => { condition.operator = (e.target as HTMLSelectElement).value as PropertyOperator; });
// Value input const valueInput = row.createEl("input", { type: "text", placeholder: "value", cls: "propsec-condition-value", }); valueInput.value = condition.value; valueInput.addEventListener("input", (e) => { condition.value = (e.target as HTMLInputElement).value; });
// Delete button const deleteBtn = row.createEl("button", { cls: "propsec-condition-delete", attr: { title: "Remove condition" } }); setIcon(deleteBtn, "x"); deleteBtn.addEventListener("click", () => { field.conditions!.splice(index, 1); if (field.conditions!.length === 0) delete field.conditions; this.renderConditionSectionContent(section, field); }); }
// ========== Template Import ==========
private showTemplateSelector(): void { const templatesFolder = this.app.vault.getAbstractFileByPath(this.templatesFolder);
if (!templatesFolder || !(templatesFolder instanceof TFolder)) { new Notice(`Templates folder "${this.templatesFolder}" not found. Check your settings.`); return; }
const templateFiles = this.getTemplateFiles(templatesFolder);
if (templateFiles.length === 0) { new Notice("No template files found in templates folder."); return; }
const modal = new TemplateSelectorModal( this.app, templateFiles, //eslint-disable-next-line @typescript-eslint/no-misused-promises async (file) => { const fields = await extractSchemaFromTemplate(this.app, file); const existingNames = new Set(this.mapping.fields.map((f) => f.name)); for (const newField of fields) { if (!existingNames.has(newField.name)) { this.mapping.fields.push(newField); } } this.mapping.sourceTemplatePath = file.path; this.doRenderFields(); } ); void modal.open(); }
private getTemplateFiles(folder: TFolder): TFile[] { const files: TFile[] = []; for (const child of folder.children) { if (child instanceof TFile && child.extension === "md") { files.push(child); } else if (child instanceof TFolder) { files.push(...this.getTemplateFiles(child)); } } return files; }
// ========== Property Filter Section ==========
private renderPropertyFilterSection(container: HTMLElement): void { if (!this.mapping.propertyFilter) { this.mapping.propertyFilter = {}; } const filter = this.mapping.propertyFilter;
const section = container.createDiv({ cls: "propsec-filter-section", });
const header = section.createDiv({ cls: "propsec-filter-header", }); header.createEl("span", { text: "Filter (optional)" }); const toggleIcon = header.createEl("span", { cls: "propsec-filter-toggle" }); setIcon(toggleIcon, "chevron-right");
const content = section.createDiv({ cls: "propsec-filter-content propsec-hidden", });
const hasFilters = filter.fileNamePattern || filter.modifiedAfter || filter.modifiedBefore || filter.createdAfter || filter.createdBefore || filter.hasProperty || filter.notHasProperty || (filter.conditions && filter.conditions.length > 0);
if (hasFilters) { content.removeClass("propsec-hidden"); setIcon(toggleIcon, "chevron-down"); }
header.addEventListener("click", () => { const isHidden = content.hasClass("propsec-hidden"); content.toggleClass("propsec-hidden", !isHidden); setIcon(toggleIcon, isHidden ? "chevron-down" : "chevron-right"); });
const grid = content.createDiv({ cls: "propsec-filter-grid" });
// File name pattern (regex) const fileNameRow = grid.createDiv({ cls: "propsec-filter-row" }); fileNameRow.createEl("label", { text: "File name matches:" }); const fileNameInput = fileNameRow.createEl("input", { type: "text", placeholder: "e.g., ^Project-.+" }); fileNameInput.value = filter.fileNamePattern || ""; fileNameInput.addEventListener("input", (e) => { filter.fileNamePattern = (e.target as HTMLInputElement).value || undefined; });
// Date filters this.createDateFilterRow(grid, "Modified after:", filter.modifiedAfter, (v) => filter.modifiedAfter = v); this.createDateFilterRow(grid, "Modified before:", filter.modifiedBefore, (v) => filter.modifiedBefore = v); this.createDateFilterRow(grid, "Created after:", filter.createdAfter, (v) => filter.createdAfter = v); this.createDateFilterRow(grid, "Created before:", filter.createdBefore, (v) => filter.createdBefore = v);
// Has property const hasPropRow = grid.createDiv({ cls: "propsec-filter-row" }); hasPropRow.createEl("label", { text: "Has property:" }); const hasPropInput = hasPropRow.createEl("input", { type: "text", placeholder: "e.g., status" }); hasPropInput.value = filter.hasProperty || ""; hasPropInput.addEventListener("input", (e) => { filter.hasProperty = (e.target as HTMLInputElement).value || undefined; }); this.attachPropertySuggest(hasPropInput);
// Missing property const notHasPropRow = grid.createDiv({ cls: "propsec-filter-row" }); notHasPropRow.createEl("label", { text: "Missing property:" }); const notHasPropInput = notHasPropRow.createEl("input", { type: "text", placeholder: "e.g., draft" }); notHasPropInput.value = filter.notHasProperty || ""; notHasPropInput.addEventListener("input", (e) => { filter.notHasProperty = (e.target as HTMLInputElement).value || undefined; }); this.attachPropertySuggest(notHasPropInput);
// Property conditions const conditionsSection = content.createDiv({ cls: "propsec-conditions-section" }); const conditionsHeader = conditionsSection.createDiv({ cls: "propsec-conditions-header" }); conditionsHeader.createEl("span", { text: "Property conditions" }); const addConditionBtn = conditionsHeader.createEl("button", { cls: "propsec-add-condition-btn" }); setIcon(addConditionBtn, "plus");
const conditionsList = conditionsSection.createDiv({ cls: "propsec-conditions-list" });
if (!filter.conditions) { filter.conditions = []; }
const renderConditions = () => { conditionsList.empty(); for (let i = 0; i < filter.conditions!.length; i++) { this.renderPropertyConditionRow(conditionsList, filter.conditions!, i, renderConditions); } };
addConditionBtn.addEventListener("click", () => { filter.conditions!.push({ property: "", operator: "equals", value: "" }); renderConditions(); });
renderConditions(); }
private createDateFilterRow( grid: HTMLElement, label: string, value: string | undefined, onChange: (value: string | undefined) => void ): void { const row = grid.createDiv({ cls: "propsec-filter-row" }); row.createEl("label", { text: label }); const input = row.createEl("input", { type: "date" }); input.value = value || ""; input.addEventListener("change", (e) => { onChange((e.target as HTMLInputElement).value || undefined); }); }
private renderPropertyConditionRow( container: HTMLElement, conditions: PropertyCondition[], index: number, onUpdate: () => void ): void { const condition = conditions[index]; const row = container.createDiv({ cls: "propsec-condition-row" });
// Property input const propInput = row.createEl("input", { type: "text", placeholder: "property", cls: "propsec-condition-property", }); propInput.value = condition.property;
// Operator select const operatorSelect = row.createEl("select", { cls: "propsec-condition-operator" });
const updateOperators = () => { const propertyType = this.getPropertyType(condition.property); const operators = getOperatorsForPropertyType(propertyType);
operatorSelect.empty(); for (const op of operators) { const option = operatorSelect.createEl("option", { value: op, text: getOperatorDisplayName(op), }); if (op === condition.operator) { option.selected = true; } }
if (!operators.includes(condition.operator)) { condition.operator = operators[0]; operatorSelect.value = operators[0]; } };
updateOperators();
// Property suggest const knownProperties = Object.keys(this.app.metadataTypeManager.properties); new PropertySuggest( this.app, propInput, knownProperties, (prop) => this.getPropertyType(prop), () => updateOperators(), (prop) => this.getPropertyDisplayName(prop) );
propInput.addEventListener("input", (e) => { condition.property = (e.target as HTMLInputElement).value; updateOperators(); });
operatorSelect.addEventListener("change", (e) => { condition.operator = (e.target as HTMLSelectElement).value as PropertyOperator; });
// Value input const valueInput = row.createEl("input", { type: "text", placeholder: "value", cls: "propsec-condition-value", }); valueInput.value = condition.value; valueInput.addEventListener("input", (e) => { condition.value = (e.target as HTMLInputElement).value; });
// Delete button const deleteBtn = row.createEl("button", { cls: "propsec-condition-delete" }); setIcon(deleteBtn, "x"); deleteBtn.addEventListener("click", () => { conditions.splice(index, 1); onUpdate(); }); }
private getPropertyType(propertyName: string): string { const propInfo = this.app.metadataTypeManager.properties[propertyName]; return propInfo?.widget ?? "text"; }
private getPropertyDisplayName(propertyKey: string): string { const propInfo = this.app.metadataTypeManager.properties[propertyKey]; return propInfo?.name ?? propertyKey; }
}
/** * Inline suggest for property names using Obsidian's native suggest UI */class PropertySuggest extends AbstractInputSuggest<string> { private properties: string[]; private onSelectCallback: (value: string) => void; private getType: (prop: string) => string; private getDisplayName: (prop: string) => string; private textInput: HTMLInputElement;
constructor( app: App, inputEl: HTMLInputElement, properties: string[], getType: (prop: string) => string, onSelect: (value: string) => void, getDisplayName?: (prop: string) => string ) { super(app, inputEl); this.textInput = inputEl; this.properties = properties; this.getType = getType; this.onSelectCallback = onSelect; this.getDisplayName = getDisplayName || ((prop) => prop); }
getSuggestions(query: string): string[] { const lowerQuery = query.toLowerCase(); return this.properties.filter(prop => prop.toLowerCase().includes(lowerQuery) ); }
renderSuggestion(value: string, el: HTMLElement): void { el.addClass("propsec-property-suggestion"); const displayName = this.getDisplayName(value); el.createSpan({ text: displayName, cls: "propsec-property-name" }); const propType = this.getType(value); el.createSpan({ text: propType, cls: "propsec-property-type" }); }
selectSuggestion(value: string, _evt: MouseEvent | KeyboardEvent): void { const displayName = this.getDisplayName(value); this.textInput.value = displayName; this.textInput.dispatchEvent(new Event("input", { bubbles: true })); this.onSelectCallback(displayName); this.close(); }}
/** * Simple modal for selecting a template file */class TemplateSelectorModal extends Modal { private files: TFile[]; private onSelect: (file: TFile) => void;
constructor(app: App, files: TFile[], onSelect: (file: TFile) => void) { super(app); this.files = files; this.onSelect = onSelect; }
onOpen(): void { const { contentEl } = this;
contentEl.createEl("h3", { text: "Select template" });
const list = contentEl.createEl("div", { cls: "propsec-template-list", });
for (const file of this.files) { const item = list.createEl("div", { cls: "propsec-template-item", }); item.setText(file.basename); item.addEventListener("click", () => { this.onSelect(file); this.close(); }); } }
onClose(): void { const { contentEl } = this; contentEl.empty(); }}