mirror of
https://github.com/signynt/virtual-content.git
synced 2026-07-22 12:40:27 +00:00
Added option to add rules based on tags rather than folders
This commit is contained in:
parent
70882c912f
commit
a67057a673
1 changed files with 247 additions and 190 deletions
437
main.ts
437
main.ts
|
|
@ -7,12 +7,20 @@ import {
|
|||
MarkdownRenderer,
|
||||
AbstractInputSuggest,
|
||||
Component,
|
||||
TFile
|
||||
TFile,
|
||||
getAllTags // Ensure getAllTags is imported
|
||||
} from 'obsidian';
|
||||
|
||||
// Interfaces
|
||||
interface Rule {
|
||||
type: 'folder' | 'tag';
|
||||
path?: string; // For folder type
|
||||
tag?: string; // For tag type
|
||||
footerText: string;
|
||||
}
|
||||
|
||||
interface VirtualFooterSettings {
|
||||
rules: { folderPath: string; footerText: string }[];
|
||||
rules: Rule[];
|
||||
renderLocation: 'footer' | 'header';
|
||||
}
|
||||
|
||||
|
|
@ -22,7 +30,7 @@ interface HTMLElementWithComponent extends HTMLElement {
|
|||
|
||||
// Constants
|
||||
const DEFAULT_SETTINGS: VirtualFooterSettings = {
|
||||
rules: [{ folderPath: '', footerText: '' }],
|
||||
rules: [{ type: 'folder', path: '', footerText: '' }], // Default to one empty folder rule
|
||||
renderLocation: 'footer'
|
||||
};
|
||||
|
||||
|
|
@ -79,118 +87,70 @@ export class MultiSuggest extends AbstractInputSuggest<string> {
|
|||
/**
|
||||
* Main plugin class for VirtualFooter.
|
||||
* This plugin allows users to define custom footer (or header) content
|
||||
* that is dynamically injected into Markdown views based on folder-specific rules.
|
||||
* that is dynamically injected into Markdown views based on folder-specific or tag-specific rules.
|
||||
*/
|
||||
export default class VirtualFooterPlugin extends Plugin {
|
||||
/**
|
||||
* Stores the plugin's settings, loaded from and saved to Obsidian's data storage.
|
||||
* Contains rules for matching folders to footer text and other display preferences.
|
||||
*/
|
||||
settings: VirtualFooterSettings;
|
||||
|
||||
/**
|
||||
* Called when the plugin is first loaded.
|
||||
* Initializes the plugin by loading settings, adding a settings tab,
|
||||
* and registering event listeners for file opening and layout changes
|
||||
* to dynamically update views with virtual footers/headers.
|
||||
*/
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.addSettingTab(new VirtualFooterSettingTab(this.app, this));
|
||||
|
||||
// Register event listeners to update views when files are opened or layout changes
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('file-open', () => this.handleActiveViewChange())
|
||||
);
|
||||
this.registerEvent(
|
||||
this.app.workspace.on('layout-change', () => this.handleActiveViewChange())
|
||||
);
|
||||
this.handleActiveViewChange(); // Initial call to process any already open files
|
||||
this.handleActiveViewChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the plugin is unloaded.
|
||||
* Performs cleanup tasks, including removing all injected content and styles
|
||||
* from all Markdown views and cleaning up any globally applied CSS classes
|
||||
* or dynamically created elements.
|
||||
*/
|
||||
async onunload() {
|
||||
this.clearAllViews(); // Clean up all individual views
|
||||
this.clearAllViews();
|
||||
|
||||
// Global cleanup for any elements potentially missed by view-specific cleanup
|
||||
// This targets dynamically created content elements.
|
||||
document.querySelectorAll(`.${CSS_DYNAMIC_CONTENT_ELEMENT}`).forEach(el => {
|
||||
const componentHolder = el as HTMLElementWithComponent;
|
||||
if (componentHolder.component) {
|
||||
componentHolder.component.unload(); // Unload associated Obsidian component
|
||||
componentHolder.component.unload();
|
||||
}
|
||||
el.remove(); // Remove the element from the DOM
|
||||
el.remove();
|
||||
});
|
||||
|
||||
// Remove global CSS classes applied for styling
|
||||
document.querySelectorAll(`.${CSS_VIRTUAL_FOOTER_CM_PADDING}`).forEach(el => el.classList.remove(CSS_VIRTUAL_FOOTER_CM_PADDING));
|
||||
document.querySelectorAll(`.${CSS_VIRTUAL_FOOTER_REMOVE_FLEX}`).forEach(el => el.classList.remove(CSS_VIRTUAL_FOOTER_REMOVE_FLEX));
|
||||
}
|
||||
|
||||
// --- Core View Handling ---
|
||||
|
||||
/**
|
||||
* Handles changes in the active view (e.g., opening a new file, switching tabs).
|
||||
* It identifies the active Markdown view and triggers processing for it.
|
||||
*/
|
||||
handleActiveViewChange() {
|
||||
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
|
||||
this._processView(activeView);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a given Markdown view to apply or remove virtual footer/header content.
|
||||
* If the view is invalid or has no file, it clears all views.
|
||||
* Otherwise, it cleans up existing content/styles from the view,
|
||||
* applies specific styles for Live Preview footer mode if applicable,
|
||||
* and then renders and injects new content if the view is in Preview or Live Preview editor mode.
|
||||
* @param view The MarkdownView to process, or null if no Markdown view is active.
|
||||
*/
|
||||
private async _processView(view: MarkdownView | null) {
|
||||
if (!view || !view.file) {
|
||||
this.clearAllViews(); // If no valid view, clear everything
|
||||
this.clearAllViews();
|
||||
return;
|
||||
}
|
||||
|
||||
// Always clean up the current view before applying new content/styles
|
||||
await this.removeStylesAndInjectedContent(view);
|
||||
|
||||
const state = view.getState();
|
||||
const isRenderInHeader = this.settings.renderLocation === 'header';
|
||||
|
||||
// Apply specific styles for Live Preview footer mode
|
||||
// These styles adjust padding to make space for the footer.
|
||||
if (state.mode === 'source' && !state.source && !isRenderInHeader) { // Live Preview editor mode, footer rendering
|
||||
if (state.mode === 'source' && !state.source && !isRenderInHeader) {
|
||||
this.applyLivePreviewFooterStyles(view);
|
||||
}
|
||||
|
||||
// Inject content if in Preview mode or Live Preview editor mode
|
||||
if (state.mode === 'preview' || (state.mode === 'source' && !state.source)) {
|
||||
await this.renderAndInjectContent(view);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Content Rendering & Injection ---
|
||||
|
||||
/**
|
||||
* Renders the appropriate footer/header content for the given view and injects it into the DOM.
|
||||
* It determines the content text based on the file path, prepares the HTML element,
|
||||
* and then injects it into the correct location (header or footer area) depending on the
|
||||
* view mode (Preview or Live Preview) and plugin settings.
|
||||
* Also attaches handlers for internal links within the injected content.
|
||||
* @param view The MarkdownView where the content will be injected.
|
||||
*/
|
||||
private async renderAndInjectContent(view: MarkdownView) {
|
||||
const filePath = view.file?.path || '';
|
||||
const contentText = this.getFooterTextForFile(filePath);
|
||||
|
||||
if (!contentText) {
|
||||
await this.removeInjectedContentDOM(view); // Ensure removal if content becomes empty
|
||||
await this.removeInjectedContentDOM(view);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -201,7 +161,6 @@ export default class VirtualFooterPlugin extends Plugin {
|
|||
const state = view.getState();
|
||||
|
||||
if (state.mode === 'preview') {
|
||||
// Target the preview mode's header or footer area
|
||||
const targetParent = view.containerEl.querySelector<HTMLElement>(
|
||||
isRenderInHeader ? SELECTOR_PREVIEW_HEADER_AREA : SELECTOR_PREVIEW_FOOTER_AREA
|
||||
);
|
||||
|
|
@ -211,14 +170,12 @@ export default class VirtualFooterPlugin extends Plugin {
|
|||
}
|
||||
} else if (state.mode === 'source' && !state.source) { // Live Preview editor mode
|
||||
if (isRenderInHeader) {
|
||||
// Target the area before the CodeMirror content container for header in Live Preview
|
||||
const cmContentContainer = view.containerEl.querySelector<HTMLElement>(SELECTOR_LIVE_PREVIEW_CONTENT_CONTAINER);
|
||||
if (cmContentContainer?.parentElement) {
|
||||
cmContentContainer.parentElement.insertBefore(contentDiv, cmContentContainer);
|
||||
injectionSuccessful = true;
|
||||
}
|
||||
} else { // Footer in Live Preview editor
|
||||
// Target the sizer element which is a reliable parent for appending footer content
|
||||
} else {
|
||||
const targetParent = view.containerEl.querySelector<HTMLElement>(SELECTOR_EDITOR_SIZER);
|
||||
if (targetParent) {
|
||||
targetParent.appendChild(contentDiv);
|
||||
|
|
@ -228,61 +185,32 @@ export default class VirtualFooterPlugin extends Plugin {
|
|||
}
|
||||
|
||||
if (injectionSuccessful) {
|
||||
// If content was successfully injected, attach internal link handlers
|
||||
this.attachInternalLinkHandlers(contentDiv, filePath, component, view);
|
||||
this.attachInternalLinkHandlers(contentDiv, filePath, component);
|
||||
} else {
|
||||
// If injection failed (e.g., target element not found), unload the component to free resources.
|
||||
// console.warn('VirtualFooterPlugin: Target for injection not found. Content not rendered.');
|
||||
component.unload();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and prepares an HTML element to hold the rendered Markdown content.
|
||||
* It sets up a `div` with appropriate CSS classes, creates an Obsidian `Component`
|
||||
* for lifecycle management of the rendered content (e.g., internal links),
|
||||
* and then renders the provided Markdown text into this `div`.
|
||||
* @param contentText The Markdown string to render.
|
||||
* @param isRenderInHeader True if the content is for the header, false for the footer.
|
||||
* @param sourcePath The path of the file for which this content is being rendered, used for Markdown rendering context.
|
||||
* @returns A promise that resolves to an object containing the created HTMLElement and its associated Component.
|
||||
*/
|
||||
private async prepareContentElement(contentText: string, isRenderInHeader: boolean, sourcePath: string): Promise<{ element: HTMLElement; component: Component }> {
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = CSS_DYNAMIC_CONTENT_ELEMENT; // Base class for all dynamic content
|
||||
contentDiv.classList.add(isRenderInHeader ? CSS_HEADER_RENDERED_CONTENT : CSS_FOOTER_RENDERED_CONTENT); // Specific class for header/footer
|
||||
contentDiv.className = CSS_DYNAMIC_CONTENT_ELEMENT;
|
||||
contentDiv.classList.add(isRenderInHeader ? CSS_HEADER_RENDERED_CONTENT : CSS_FOOTER_RENDERED_CONTENT);
|
||||
|
||||
// Create a new component to manage the lifecycle of the rendered Markdown.
|
||||
// This is important for Obsidian's internal link handling and other dynamic features.
|
||||
const component = new Component();
|
||||
component.load(); // Load the component
|
||||
(contentDiv as HTMLElementWithComponent).component = component; // Store component reference on the element
|
||||
component.load();
|
||||
(contentDiv as HTMLElementWithComponent).component = component;
|
||||
|
||||
// Render the Markdown content into the div.
|
||||
await MarkdownRenderer.render(this.app, contentText, contentDiv, sourcePath, component);
|
||||
return { element: contentDiv, component };
|
||||
}
|
||||
|
||||
// --- DOM Styling & Cleanup ---
|
||||
|
||||
/**
|
||||
* Applies specific CSS classes to elements within a MarkdownView when rendering
|
||||
* a footer in Live Preview mode. These classes adjust padding and layout
|
||||
* to accommodate the injected footer content.
|
||||
* @param view The MarkdownView to style.
|
||||
*/
|
||||
private applyLivePreviewFooterStyles(view: MarkdownView): void {
|
||||
const contentEl = view.containerEl.querySelector<HTMLDivElement>(SELECTOR_EDITOR_CONTENT_AREA);
|
||||
const containerEl = view.containerEl.querySelector<HTMLDivElement>(SELECTOR_EDITOR_CONTENT_CONTAINER_PARENT);
|
||||
contentEl?.classList.add(CSS_VIRTUAL_FOOTER_CM_PADDING); // Adds bottom padding to CodeMirror content
|
||||
containerEl?.classList.add(CSS_VIRTUAL_FOOTER_REMOVE_FLEX); // Modifies flex behavior of parent container
|
||||
contentEl?.classList.add(CSS_VIRTUAL_FOOTER_CM_PADDING);
|
||||
containerEl?.classList.add(CSS_VIRTUAL_FOOTER_REMOVE_FLEX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the CSS classes previously applied by `applyLivePreviewFooterStyles`.
|
||||
* This is used to clean up styles when the footer is removed or the view mode changes.
|
||||
* @param view The MarkdownView from which to remove styles.
|
||||
*/
|
||||
private removeLivePreviewFooterStyles(view: MarkdownView): void {
|
||||
const contentEl = view.containerEl.querySelector<HTMLDivElement>(SELECTOR_EDITOR_CONTENT_AREA);
|
||||
const containerEl = view.containerEl.querySelector<HTMLDivElement>(SELECTOR_EDITOR_CONTENT_CONTAINER_PARENT);
|
||||
|
|
@ -290,40 +218,24 @@ export default class VirtualFooterPlugin extends Plugin {
|
|||
containerEl?.classList.remove(CSS_VIRTUAL_FOOTER_REMOVE_FLEX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes all dynamically injected content elements (footers/headers) from a specific view.
|
||||
* It iterates through known parent selectors where content might be injected,
|
||||
* finds elements with the `CSS_DYNAMIC_CONTENT_ELEMENT` class, unloads their
|
||||
* associated Obsidian components, and then removes the elements from the DOM.
|
||||
* @param view The MarkdownView from which to remove injected content.
|
||||
*/
|
||||
private async removeInjectedContentDOM(view: MarkdownView) {
|
||||
SELECTORS_POTENTIAL_DYNAMIC_CONTENT_PARENTS.forEach(selector => {
|
||||
const parentEl = view.containerEl.querySelector(selector);
|
||||
parentEl?.querySelectorAll(`.${CSS_DYNAMIC_CONTENT_ELEMENT}`).forEach(el => {
|
||||
const componentHolder = el as HTMLElementWithComponent;
|
||||
if (componentHolder.component) {
|
||||
componentHolder.component.unload(); // Unload the component to free resources
|
||||
componentHolder.component.unload();
|
||||
}
|
||||
el.remove(); // Remove the element from the DOM
|
||||
el.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A utility method to remove both applied Live Preview styles and injected DOM content
|
||||
* from a given Markdown view.
|
||||
* @param view The MarkdownView to clean up.
|
||||
*/
|
||||
private async removeStylesAndInjectedContent(view: MarkdownView) {
|
||||
this.removeLivePreviewFooterStyles(view);
|
||||
await this.removeInjectedContentDOM(view);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all injected content and styles from all open Markdown views in the workspace.
|
||||
* Iterates through all 'markdown' type leaves and calls `removeStylesAndInjectedContent` for each.
|
||||
*/
|
||||
private clearAllViews(): void {
|
||||
this.app.workspace.getLeavesOfType('markdown').forEach(leaf => {
|
||||
if (leaf.view instanceof MarkdownView) {
|
||||
|
|
@ -332,66 +244,141 @@ export default class VirtualFooterPlugin extends Plugin {
|
|||
});
|
||||
}
|
||||
|
||||
// --- Utility Methods ---
|
||||
|
||||
/**
|
||||
* Determines the appropriate footer/header text for a given file path based on the
|
||||
* plugin's configured rules. It finds the rule with the longest matching folder path.
|
||||
* @param filePath The path of the file to find footer text for.
|
||||
* @returns The footer/header text string if a matching rule is found, otherwise an empty string.
|
||||
*/
|
||||
private getFooterTextForFile(filePath: string): string {
|
||||
let bestMatchPath = '';
|
||||
let footerText = '';
|
||||
// Iterate through rules to find the most specific match (longest folder path)
|
||||
let bestMatchPath = ""; // Stores the path string of the best match, "" is least specific
|
||||
let folderRuleText = "";
|
||||
|
||||
// Check folder rules first
|
||||
for (const rule of this.settings.rules) {
|
||||
if (filePath.startsWith(rule.folderPath) && rule.folderPath.length >= bestMatchPath.length) {
|
||||
bestMatchPath = rule.folderPath;
|
||||
footerText = rule.footerText;
|
||||
if (rule.type === 'folder' && rule.path !== undefined) {
|
||||
let isMatch = false;
|
||||
let currentRuleSpecificity = -1;
|
||||
|
||||
if (rule.path === "") { // Empty string path rule applies to all files
|
||||
isMatch = true;
|
||||
currentRuleSpecificity = 0; // Least specific
|
||||
} else if (rule.path === "/") { // Root folder path rule
|
||||
const fileForPath = this.app.vault.getAbstractFileByPath(filePath);
|
||||
if (fileForPath instanceof TFile && fileForPath.parent && fileForPath.parent.isRoot()) {
|
||||
isMatch = true;
|
||||
currentRuleSpecificity = 1; // More specific than ""
|
||||
}
|
||||
} else { // Regular folder path (e.g., "Meetings/")
|
||||
if (filePath.startsWith(rule.path)) {
|
||||
isMatch = true;
|
||||
currentRuleSpecificity = rule.path.length;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
const bestMatchSpecificity = (bestMatchPath === "") ? 0 : (bestMatchPath === "/" ? 1 : bestMatchPath.length);
|
||||
if (currentRuleSpecificity >= bestMatchSpecificity) {
|
||||
bestMatchPath = rule.path;
|
||||
folderRuleText = rule.footerText;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return footerText;
|
||||
|
||||
if (folderRuleText) {
|
||||
return folderRuleText; // Folder rule takes precedence
|
||||
}
|
||||
|
||||
// If no folder rule matched, check tag rules
|
||||
const file = this.app.vault.getAbstractFileByPath(filePath);
|
||||
if (file instanceof TFile) {
|
||||
const fileCache = this.app.metadataCache.getFileCache(file);
|
||||
if (fileCache) {
|
||||
// Use getAllTags to include frontmatter tags. It returns tags with '#'.
|
||||
const allTagsInFileWithHash = getAllTags(fileCache);
|
||||
const fileTags = allTagsInFileWithHash ? allTagsInFileWithHash.map(tag => tag.substring(1)) : []; // Remove leading '#'
|
||||
|
||||
for (const rule of this.settings.rules) {
|
||||
if (rule.type === 'tag' && rule.tag && fileTags.includes(rule.tag)) {
|
||||
return rule.footerText; // First matching tag rule
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ''; // No rule matched
|
||||
}
|
||||
|
||||
/**
|
||||
* Attaches click event handlers to internal links (`<a class="internal-link">`)
|
||||
* within the injected content container. This ensures that Obsidian's internal link
|
||||
* navigation (including opening in new panes with Ctrl/Cmd click) works correctly.
|
||||
* @param container The HTMLElement containing the rendered Markdown content.
|
||||
* @param sourcePath The path of the file where the content is displayed, used as context for link resolution.
|
||||
* @param component The Obsidian Component associated with the rendered content, used to register DOM events.
|
||||
* @param view The MarkdownView instance, currently unused but kept for potential future use.
|
||||
*/
|
||||
private attachInternalLinkHandlers(container: HTMLElement, sourcePath: string, component: Component, view: MarkdownView) {
|
||||
// Register a DOM event listener on the component for click events within the container.
|
||||
private attachInternalLinkHandlers(container: HTMLElement, sourcePath: string, component: Component) {
|
||||
// Handle left-clicks and Ctrl/Meta + left-clicks
|
||||
component.registerDomEvent(container, 'click', (event: MouseEvent) => {
|
||||
// Ensure it's a left click (button 0)
|
||||
if (event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
const link = target.closest('a.internal-link') as HTMLAnchorElement; // Find the closest internal link ancestor
|
||||
const link = target.closest('a.internal-link') as HTMLAnchorElement;
|
||||
|
||||
if (link) {
|
||||
event.preventDefault(); // Prevent default link navigation
|
||||
const href = link.dataset.href; // Get the link destination from data-href attribute
|
||||
event.preventDefault(); // Prevent default navigation for all captured internal links
|
||||
const href = link.dataset.href;
|
||||
if (href) {
|
||||
const newPane = event.ctrlKey || event.metaKey; // Check for Ctrl/Cmd key for new pane
|
||||
this.app.workspace.openLinkText(href, sourcePath, newPane); // Use Obsidian API to open link
|
||||
const newPane = event.ctrlKey || event.metaKey; // For left click, new pane is only for Ctrl/Meta
|
||||
this.app.workspace.openLinkText(href, sourcePath, newPane);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle middle-clicks (auxiliary clicks)
|
||||
component.registerDomEvent(container, 'auxclick', (event: MouseEvent) => {
|
||||
// Ensure it's a middle click (button 1)
|
||||
if (event.button !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target as HTMLElement;
|
||||
const link = target.closest('a.internal-link') as HTMLAnchorElement;
|
||||
|
||||
if (link) {
|
||||
event.preventDefault(); // Prevent default middle-click behavior (e.g., autoscroll)
|
||||
const href = link.dataset.href;
|
||||
if (href) {
|
||||
// Middle click always opens in a new pane
|
||||
this.app.workspace.openLinkText(href, sourcePath, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Settings Persistence ---
|
||||
|
||||
/**
|
||||
* Loads plugin settings from Obsidian's data storage.
|
||||
* Merges stored settings with default settings to ensure all properties are present.
|
||||
*/
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
const loadedSettings = await this.loadData();
|
||||
if (loadedSettings && loadedSettings.rules) {
|
||||
// Migration for rules that don't have a 'type' (from older versions)
|
||||
loadedSettings.rules = loadedSettings.rules.map((rule: any) => {
|
||||
if (typeof rule.folderPath === 'string' && typeof rule.type === 'undefined') {
|
||||
return {
|
||||
type: 'folder',
|
||||
path: rule.folderPath, // Use 'path' instead of 'folderPath'
|
||||
footerText: rule.footerText
|
||||
};
|
||||
}
|
||||
// Ensure new rules have path/tag initialized if not present
|
||||
if (rule.type === 'folder' && rule.path === undefined) rule.path = '';
|
||||
if (rule.type === 'tag' && rule.tag === undefined) rule.tag = '';
|
||||
return rule;
|
||||
});
|
||||
}
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedSettings);
|
||||
|
||||
// Ensure default rule structure if rules array is empty or missing after loading
|
||||
if (!this.settings.rules || this.settings.rules.length === 0) {
|
||||
this.settings.rules = [{ type: 'folder', path: '', footerText: '' }];
|
||||
} else {
|
||||
// Final check to ensure all rules have a type and initialized path/tag
|
||||
this.settings.rules.forEach(rule => {
|
||||
if (!rule.type) rule.type = 'folder'; // Default to folder if somehow still missing
|
||||
if (rule.type === 'folder' && rule.path === undefined) rule.path = '';
|
||||
if (rule.type === 'tag' && rule.tag === undefined) rule.tag = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current plugin settings to Obsidian's data storage.
|
||||
*/
|
||||
async saveSettings() {
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
|
|
@ -400,6 +387,7 @@ export default class VirtualFooterPlugin extends Plugin {
|
|||
class VirtualFooterSettingTab extends PluginSettingTab {
|
||||
plugin: VirtualFooterPlugin;
|
||||
private allFilePaths: Set<string> | null = null;
|
||||
private allTagsCache: Set<string> | null = null;
|
||||
|
||||
constructor(app: App, plugin: VirtualFooterPlugin) {
|
||||
super(app, plugin);
|
||||
|
|
@ -409,22 +397,45 @@ class VirtualFooterSettingTab extends PluginSettingTab {
|
|||
private generateAllFilePaths(): Set<string> {
|
||||
if (this.allFilePaths) return this.allFilePaths;
|
||||
|
||||
const paths = new Set<string>(['/']);
|
||||
const paths = new Set<string>(['/']); // Add root path
|
||||
this.plugin.app.vault.getAllLoadedFiles().forEach(file => {
|
||||
if (file instanceof TFile && file.parent) {
|
||||
paths.add(file.parent.path === '/' ? '/' : file.parent.path + '/');
|
||||
const parentPath = file.parent.isRoot() ? '/' : (file.parent.path.endsWith('/') ? file.parent.path : file.parent.path + '/');
|
||||
if (parentPath !== '/') paths.add(parentPath);
|
||||
} else if ('children' in file && file.path !== '/') { // TFolder
|
||||
paths.add(file.path + '/');
|
||||
const folderPath = file.path.endsWith('/') ? file.path : file.path + '/';
|
||||
paths.add(folderPath);
|
||||
}
|
||||
});
|
||||
this.allFilePaths = paths;
|
||||
return paths;
|
||||
}
|
||||
private generateAllTagsInVault(): Set<string> {
|
||||
if (this.allTagsCache) return this.allTagsCache;
|
||||
|
||||
const collectedTags = new Set<string>();
|
||||
const markdownFiles = this.plugin.app.vault.getMarkdownFiles();
|
||||
|
||||
for (const file of markdownFiles) {
|
||||
const fileCache = this.plugin.app.metadataCache.getFileCache(file);
|
||||
if (fileCache) {
|
||||
const tagsInFile = getAllTags(fileCache);
|
||||
if (tagsInFile) {
|
||||
tagsInFile.forEach(tag => {
|
||||
collectedTags.add(tag.startsWith('#') ? tag.substring(1) : tag);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.allTagsCache = collectedTags;
|
||||
return collectedTags;
|
||||
}
|
||||
|
||||
display(): void {
|
||||
const { containerEl } = this;
|
||||
containerEl.empty();
|
||||
this.allFilePaths = null; // Reset cached paths for re-display
|
||||
this.allFilePaths = null;
|
||||
this.allTagsCache = null;
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Render location')
|
||||
|
|
@ -445,6 +456,10 @@ class VirtualFooterSettingTab extends PluginSettingTab {
|
|||
if (!this.plugin.settings.rules) {
|
||||
this.plugin.settings.rules = [];
|
||||
}
|
||||
if (this.plugin.settings.rules.length === 0) {
|
||||
this.plugin.settings.rules.push({ type: 'folder', path: '', footerText: '' });
|
||||
}
|
||||
|
||||
|
||||
this.plugin.settings.rules.forEach((rule, index) => {
|
||||
this.renderRuleControls(rule, index, rulesContainer);
|
||||
|
|
@ -455,48 +470,90 @@ class VirtualFooterSettingTab extends PluginSettingTab {
|
|||
.setButtonText('Add rule')
|
||||
.setClass('virtual-footer-add-button')
|
||||
.onClick(async () => {
|
||||
this.plugin.settings.rules.push({ folderPath: '', footerText: '' });
|
||||
this.plugin.settings.rules.push({ type: 'folder', path: '', footerText: '' });
|
||||
await this.plugin.saveSettings();
|
||||
this.display(); // Re-render the entire settings tab
|
||||
this.plugin.handleActiveViewChange(); // Update views
|
||||
this.display();
|
||||
this.plugin.handleActiveViewChange();
|
||||
}));
|
||||
}
|
||||
|
||||
private renderRuleControls(rule: { folderPath: string; footerText: string }, index: number, containerEl: HTMLElement) {
|
||||
private renderRuleControls(rule: Rule, index: number, containerEl: HTMLElement) {
|
||||
const ruleDiv = containerEl.createDiv();
|
||||
ruleDiv.addClass('rule');
|
||||
|
||||
|
||||
new Setting(ruleDiv)
|
||||
.setName(`Folder path ${index + 1}`)
|
||||
.setDesc('Path in the vault. Content will apply to notes in this folder and its subfolders. Use "/" for all notes.')
|
||||
.addText(text => {
|
||||
text.setPlaceholder('e.g., Meetings/ or /')
|
||||
.setValue(rule.folderPath)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.rules[index].folderPath = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.handleActiveViewChange();
|
||||
});
|
||||
new MultiSuggest(text.inputEl, this.generateAllFilePaths(), (selectedPath) => {
|
||||
this.plugin.settings.rules[index].folderPath = selectedPath;
|
||||
this.plugin.saveSettings(); // await not strictly needed if not chaining
|
||||
.setName(`Rule ${index + 1} type`)
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOption('folder', 'Folder')
|
||||
.addOption('tag', 'Tag')
|
||||
.setValue(rule.type)
|
||||
.onChange(async (value: 'folder' | 'tag') => {
|
||||
rule.type = value;
|
||||
if (value === 'folder') {
|
||||
delete rule.tag;
|
||||
if (rule.path === undefined) rule.path = '';
|
||||
} else {
|
||||
delete rule.path;
|
||||
if (rule.tag === undefined) rule.tag = '';
|
||||
}
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
this.plugin.handleActiveViewChange();
|
||||
text.setValue(selectedPath); // Ensure text input updates
|
||||
}, this.plugin.app);
|
||||
});
|
||||
|
||||
}));
|
||||
|
||||
if (rule.type === 'folder') {
|
||||
new Setting(ruleDiv)
|
||||
.setName(`Folder path`)
|
||||
.setDesc('Path for the rule. Use "" for all files, "/" for root folder files, or "FolderName/" for specific folders and their subfolders.')
|
||||
.addText(text => {
|
||||
text.setPlaceholder('e.g., Meetings/, /, or leave empty for all')
|
||||
.setValue(rule.path || '')
|
||||
.onChange(async (value) => {
|
||||
rule.path = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.handleActiveViewChange();
|
||||
});
|
||||
new MultiSuggest(text.inputEl, this.generateAllFilePaths(), (selectedPath) => {
|
||||
rule.path = selectedPath;
|
||||
this.plugin.saveSettings();
|
||||
this.plugin.handleActiveViewChange();
|
||||
text.setValue(selectedPath);
|
||||
}, this.plugin.app);
|
||||
});
|
||||
} else if (rule.type === 'tag') {
|
||||
new Setting(ruleDiv)
|
||||
.setName(`Tag value`)
|
||||
.setDesc('Tag to match (without the #).')
|
||||
.addText(text => {
|
||||
text.setPlaceholder('e.g., important or project/alpha')
|
||||
.setValue(rule.tag || '')
|
||||
.onChange(async (value) => {
|
||||
rule.tag = value.startsWith('#') ? value.substring(1) : value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.handleActiveViewChange();
|
||||
});
|
||||
new MultiSuggest(text.inputEl, this.generateAllTagsInVault(), (selectedTag) => {
|
||||
const normalizedTag = selectedTag.startsWith('#') ? selectedTag.substring(1) : selectedTag;
|
||||
rule.tag = normalizedTag;
|
||||
this.plugin.saveSettings();
|
||||
this.plugin.handleActiveViewChange();
|
||||
text.setValue(normalizedTag);
|
||||
}, this.plugin.app);
|
||||
});
|
||||
}
|
||||
|
||||
new Setting(ruleDiv)
|
||||
.setName(`Content text ${index + 1}`)
|
||||
.setName(`Content text`)
|
||||
.setDesc('Markdown text to display.')
|
||||
.addTextArea(text => text
|
||||
.setPlaceholder('Enter your markdown content here...')
|
||||
.setValue(rule.footerText)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.rules[index].footerText = value;
|
||||
rule.footerText = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.plugin.handleActiveViewChange();
|
||||
}));
|
||||
|
||||
|
||||
new Setting(ruleDiv)
|
||||
.addButton(button => button
|
||||
.setButtonText('Delete rule')
|
||||
|
|
@ -504,10 +561,10 @@ class VirtualFooterSettingTab extends PluginSettingTab {
|
|||
.onClick(async () => {
|
||||
this.plugin.settings.rules.splice(index, 1);
|
||||
await this.plugin.saveSettings();
|
||||
this.display(); // Re-render the entire settings tab
|
||||
this.plugin.handleActiveViewChange(); // Update views
|
||||
this.display();
|
||||
this.plugin.handleActiveViewChange();
|
||||
}));
|
||||
|
||||
|
||||
ruleDiv.createEl('hr');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue