diff --git a/main.ts b/main.ts index 55bc0fa..6fd9eb4 100644 --- a/main.ts +++ b/main.ts @@ -14,6 +14,7 @@ interface LLMAgentSettings { promptsFolder: string; // Новое поле enableFileReferences: boolean; // Новое поле enablePromptReferences: boolean; // Новое поле + excludedFolders: string[]; // Новое поле: Список папок для исключения из автокомплита } const DEFAULT_SETTINGS: LLMAgentSettings = { @@ -22,7 +23,8 @@ const DEFAULT_SETTINGS: LLMAgentSettings = { defaultModel: 'gemini-2.5-flash', promptsFolder: 'prompts', // Папка с промптами по умолчанию enableFileReferences: true, - enablePromptReferences: true + enablePromptReferences: true, + excludedFolders: ['templates'] // Папка "templates" по умолчанию исключена }; // ----------------------------------------------- Main Plugin Class --------------------------------------------------------------- @@ -198,5 +200,55 @@ class SampleSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); }) ); + + // Новое поле для настройки исключенных папок + new Setting(containerEl) + .setName('Исключенные папки для ссылок') + .setDesc('Список относительных путей к папкам, файлы из которых не будут предлагаться в автодополнении ссылок (@/@@).') + .addToggle((toggle) => + toggle.setValue(this.plugin.settings.enableFileReferences).onChange(async (value) => { + this.plugin.settings.enableFileReferences = value; + await this.plugin.saveSettings(); + }) + ); + + // Динамический список исключенных папок + this.plugin.settings.excludedFolders.forEach((folder, index) => { + new Setting(containerEl) + .setClass('llm-agent-excluded-folder-item') // Добавляем класс для стилизации + .addText((text) => { + text.setPlaceholder('Путь к папке (например, templates/)' + index) + .setValue(folder) + .onChange(async (value) => { + this.plugin.settings.excludedFolders[index] = value; + await this.plugin.saveSettings(); + // Очищаем кэш автокомплита при изменении настроек + this.plugin.eventBus.emit('settings-changed'); + }); + }) + .addExtraButton((button) => { + button + .setIcon('x') + .setTooltip('Удалить папку') + .onClick(async () => { + this.plugin.settings.excludedFolders.splice(index, 1); + await this.plugin.saveSettings(); + this.display(); // Перерисовать настройки для обновления списка + // Очищаем кэш автокомплита при изменении настроек + this.plugin.eventBus.emit('settings-changed'); + }); + }); + }); + + new Setting(containerEl).addButton((button) => { + button + .setButtonText('Добавить папку') + .setIcon('plus') + .onClick(async () => { + this.plugin.settings.excludedFolders.push(''); // Добавляем пустое поле + await this.plugin.saveSettings(); + this.display(); // Перерисовать настройки для обновления списка + }); + }); } } diff --git a/src/css/styles.css b/src/css/styles.css index a078dd7..b56efae 100644 --- a/src/css/styles.css +++ b/src/css/styles.css @@ -770,4 +770,23 @@ .reference-autocomplete::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-bg-hover); +} + +/* ----------------------------------------------- Settings Views ------------------------------------------------------------ */ + +.setting-item.llm-agent-excluded-folder-item { + padding-left: 30px; /* Отступ для элементов списка */ + border-left: 2px solid var(--background-modifier-border); /* Разделитель */ + margin-bottom: 5px; + margin-top: 5px; +} + +.setting-item.llm-agent-excluded-folder-item .setting-item-control { + display: flex; + align-items: center; + gap: 5px; +} + +.setting-item.llm-agent-excluded-folder-item .setting-item-control input { + flex-grow: 1; /* Поле ввода занимает все доступное пространство */ } \ No newline at end of file diff --git a/src/references/AutocompleteManager.ts b/src/references/AutocompleteManager.ts index c54b9ac..a74cb50 100644 --- a/src/references/AutocompleteManager.ts +++ b/src/references/AutocompleteManager.ts @@ -9,114 +9,121 @@ import LLMAgentPlugin from 'main'; // ----------------------------------------------- Interfaces --------------------------------------------------------------- export interface AutocompleteItem { - name: string; - path: string; - type: 'file' | 'prompt'; - icon: string; // Obsidian icon name - file?: TFile; // Ссылка на файл Obsidian + name: string; + path: string; + type: 'file' | 'prompt'; + icon: string; // Obsidian icon name + file?: TFile; // Ссылка на файл Obsidian } // ----------------------------------------------- Autocomplete Manager --------------------------------------------------------------- export class AutocompleteManager { - private plugin: LLMAgentPlugin; - private fileCache: Map = new Map(); - private promptCache: Map = new Map(); - private lastCacheUpdate = 0; - private readonly CACHE_TTL = 30000; // 30 секунд + private plugin: LLMAgentPlugin; + private fileCache: Map = new Map(); + private promptCache: Map = new Map(); + private lastCacheUpdate = 0; + private readonly CACHE_TTL = 30000; // 30 секунд - constructor(plugin: LLMAgentPlugin) { - this.plugin = plugin; - this.setupFileWatcher(); - } + constructor(plugin: LLMAgentPlugin) { + this.plugin = plugin; + this.setupFileWatcher(); + // Слушаем изменения настроек для очистки кеша + this.plugin.eventBus.on('settings-changed', this.clearCache.bind(this)); + } - // ----------------------------------------------- Public Methods --------------------------------------------------------------- + // ----------------------------------------------- Public Methods --------------------------------------------------------------- - /** - * Получает список файлов для автокомплита - * @param query - поисковый запрос - * @returns массив файлов для автокомплита - */ - async getFileOptions(query: string): Promise { - if (!this.plugin.settings.enableFileReferences) { - return []; - } + /** + * Получает список файлов для автокомплита + * @param query - поисковый запрос + * @returns массив файлов для автокомплита + */ + async getFileOptions(query: string): Promise { + if (!this.plugin.settings.enableFileReferences) { + return []; + } - await this.updateCacheIfNeeded(); - - const allFiles = this.fileCache.get('all') || []; - - if (!query.trim()) { - return allFiles.slice(0, 10); // Показываем первые 10 файлов - } + await this.updateCacheIfNeeded(); - const lowercaseQuery = query.toLowerCase(); - return allFiles - .filter(item => - item.name.toLowerCase().includes(lowercaseQuery) || - item.path.toLowerCase().includes(lowercaseQuery) - ) - .slice(0, 10); - } + const allFiles = this.fileCache.get('all') || []; - /** - * Получает список промптов для автокомплита - * @param query - поисковый запрос - * @returns массив промптов для автокомплита - */ - async getPromptOptions(query: string): Promise { - if (!this.plugin.settings.enablePromptReferences) { - return []; - } + if (!query.trim()) { + return allFiles.slice(0, 10); // Показываем первые 10 файлов + } - await this.updateCacheIfNeeded(); - - const allPrompts = this.promptCache.get('all') || []; - - if (!query.trim()) { - return allPrompts.slice(0, 10); - } + const lowercaseQuery = query.toLowerCase(); + return allFiles + .filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery)) + .slice(0, 10); + } - const lowercaseQuery = query.toLowerCase(); - return allPrompts - .filter(item => - item.name.toLowerCase().includes(lowercaseQuery) || - item.path.toLowerCase().includes(lowercaseQuery) - ) - .slice(0, 10); - } + /** + * Получает список промптов для автокомплита + * @param query - поисковый запрос + * @returns массив промптов для автокомплита + */ + async getPromptOptions(query: string): Promise { + if (!this.plugin.settings.enablePromptReferences) { + return []; + } - /** - * Очищает кеш (используется при изменении настроек) - */ - clearCache(): void { - this.fileCache.clear(); - this.promptCache.clear(); - this.lastCacheUpdate = 0; - } + await this.updateCacheIfNeeded(); - // ----------------------------------------------- Private Methods --------------------------------------------------------------- + const allPrompts = this.promptCache.get('all') || []; - /** - * Обновляет кеш, если он устарел - */ - private async updateCacheIfNeeded(): Promise { - const now = Date.now(); - if (now - this.lastCacheUpdate > this.CACHE_TTL) { - await this.updateCache(); - this.lastCacheUpdate = now; - } - } + if (!query.trim()) { + return allPrompts.slice(0, 10); + } - /** - * Обновляет кеш файлов и промптов - */ + const lowercaseQuery = query.toLowerCase(); + return allPrompts + .filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery)) + .slice(0, 10); + } + + /** + * Очищает кеш (используется при изменении настроек) + */ + clearCache(): void { + this.fileCache.clear(); + this.promptCache.clear(); + this.lastCacheUpdate = 0; + } + + // ----------------------------------------------- Private Methods --------------------------------------------------------------- + + /** + * Обновляет кеш, если он устарел + */ + private async updateCacheIfNeeded(): Promise { + const now = Date.now(); + if (now - this.lastCacheUpdate > this.CACHE_TTL) { + await this.updateCache(); + this.lastCacheUpdate = now; + } + } + + /** + * Обновляет кеш файлов и промптов + */ private async updateCache(): Promise { const files = this.plugin.app.vault.getMarkdownFiles(); const fileItems: AutocompleteItem[] = []; const promptItems: AutocompleteItem[] = []; + // Нормализуем пути исключенных папок + const normalizedExcludedFolders = this.plugin.settings.excludedFolders + .filter(folder => folder.trim() !== '') + .map(folder => folder.endsWith('/') ? folder : folder + '/') // Убедимся, что заканчивается на слэш + .map(folder => folder.startsWith('/') ? folder.substring(1) : folder); // Удаляем начальный слэш + for (const file of files) { + // Пропускаем файлы из исключенных папок + if (this.isFileInExcludedFolder(file, normalizedExcludedFolders)) { + continue; + } + const item: AutocompleteItem = { name: file.basename, path: file.path, @@ -139,10 +146,10 @@ export class AutocompleteManager { this.fileCache.set('all', fileItems); this.promptCache.set('all', promptItems); } - - /** - * Проверяет, является ли файл промптом - */ + + /** + * Проверяет, является ли файл промптом + */ private isPromptFile(file: TFile): boolean { const promptsFolder = this.plugin.settings.promptsFolder; if (!promptsFolder) return false; @@ -154,56 +161,76 @@ export class AutocompleteManager { file.path === normalizedPromptsPath; } - /** - * Возвращает иконку для файла на основе Obsidian иконок - */ - private getFileIcon(file: TFile): string { - if (this.isPromptFile(file)) { - return 'zap'; // Иконка промпта - } - - // Определяем иконку по расширению - const extension = file.extension.toLowerCase(); - switch (extension) { - case 'md': - return 'file-text'; - case 'js': - case 'ts': - return 'code'; - case 'json': - return 'brackets'; - case 'css': - return 'palette'; - case 'png': - case 'jpg': - case 'jpeg': - case 'gif': - return 'image'; - default: - return 'file'; - } - } + /** + * Возвращает иконку для файла на основе Obsidian иконок + */ + private getFileIcon(file: TFile): string { + if (this.isPromptFile(file)) { + return 'zap'; // Иконка промпта + } + + // Определяем иконку по расширению + const extension = file.extension.toLowerCase(); + switch (extension) { + case 'md': + return 'file-text'; + case 'js': + case 'ts': + return 'code'; + case 'json': + return 'brackets'; + case 'css': + return 'palette'; + case 'png': + case 'jpg': + case 'jpeg': + case 'gif': + return 'image'; + default: + return 'file'; + } + } + + /** + * Настраивает отслеживание изменений файлов для обновления кеша + */ + private setupFileWatcher(): void { + this.plugin.registerEvent( + this.plugin.app.vault.on('create', () => { + this.clearCache(); + }) + ); + + this.plugin.registerEvent( + this.plugin.app.vault.on('delete', () => { + this.clearCache(); + }) + ); + + this.plugin.registerEvent( + this.plugin.app.vault.on('rename', () => { + this.clearCache(); + }) + ); + } /** - * Настраивает отслеживание изменений файлов для обновления кеша + * Проверяет, находится ли файл в одной из исключенных папок. + * @param file - файл Obsidian. + * @param excludedFolders - массив нормализованных путей исключенных папок. + * @returns true, если файл находится в исключенной папке. */ - private setupFileWatcher(): void { - this.plugin.registerEvent( - this.plugin.app.vault.on('create', () => { - this.clearCache(); - }) - ); + private isFileInExcludedFolder(file: TFile, excludedFolders: string[]): boolean { + const filePath = file.path.toLowerCase(); // Для регистронезависимого сравнения - this.plugin.registerEvent( - this.plugin.app.vault.on('delete', () => { - this.clearCache(); - }) - ); - - this.plugin.registerEvent( - this.plugin.app.vault.on('rename', () => { - this.clearCache(); - }) - ); + for (const folder of excludedFolders) { + const normalizedFolder = folder.toLowerCase(); + // Проверяем, начинается ли путь файла с пути исключенной папки + // Или если файл находится прямо в корне исключенной папки (н.п. "folder/file.md") + if (filePath.startsWith(normalizedFolder) ) { + return true; + } + } + return false; } -} \ No newline at end of file +} diff --git a/src/references/ReferenceParser.ts b/src/references/ReferenceParser.ts index 93f8ffc..70ba8f6 100644 --- a/src/references/ReferenceParser.ts +++ b/src/references/ReferenceParser.ts @@ -6,140 +6,155 @@ // ----------------------------------------------- Interfaces --------------------------------------------------------------- export interface ReferenceMatch { - fullMatch: string; // "@filename" или "@@filename" - prefix: string; // "@" или "@@" - name: string; // "filename" - startIndex: number; - endIndex: number; - type: 'file' | 'prompt'; + fullMatch: string; // "@filename" или "@@filename" + prefix: string; // "@" или "@@" + name: string; // "filename" + startIndex: number; + endIndex: number; + type: 'file' | 'prompt'; } export interface FileReference { - type: 'file' | 'prompt'; - name: string; - path: string; - permanent: boolean; // true для @@ и ## - // TODO: добавить snapshotId при реализации снапшотов + type: 'file' | 'prompt'; + name: string; + path: string; + permanent: boolean; // true для @@ и ## + // TODO: добавить snapshotId при реализации снапшотов } // ----------------------------------------------- Reference Parser --------------------------------------------------------------- export class ReferenceParser { - - /** - * Парсит текст и находит все @/@@ и #/## ссылки - * @param text - текст для парсинга - * @returns массив найденных ссылок - */ - static parseReferences(text: string): ReferenceMatch[] { - const matches: ReferenceMatch[] = []; - - // Регулярное выражение для поиска @/@@ и #/## ссылок - // Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов - const regex = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g; - - let match; - while ((match = regex.exec(text)) !== null) { - const prefix = match[1]; - const quotedName = match[2]; - const unquotedName = match[3]; - const name = quotedName || unquotedName; - - matches.push({ - fullMatch: match[0], - prefix: prefix, - name: name, - startIndex: match.index, - endIndex: match.index + match[0].length, - type: prefix.startsWith('@') ? 'file' : 'prompt' - }); - } - - return matches; - } - - /** - * Проверяет, является ли позиция курсора внутри незавершённой ссылки - * @param text - текст сообщения - * @param cursorPosition - позиция курсора - * @returns информация о текущей ссылке или null - */ - static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null { - // Ищем незавершенные ссылки перед курсором - const beforeCursor = text.substring(0, cursorPosition); - - // Регулярное выражение для поиска начала ссылки без завершения - const incompleteRegex = /(@{1,2}|#{1,2})([^\s@#"]*?)$/; - const match = beforeCursor.match(incompleteRegex); - - if (match) { - const prefix = match[1]; - const partialName = match[2]; - const startIndex = beforeCursor.length - match[0].length; - - return { - fullMatch: match[0], - prefix: prefix, - name: partialName, - startIndex: startIndex, - endIndex: cursorPosition, - type: prefix.startsWith('@') ? 'file' : 'prompt' - }; - } - - return null; - } - - /** - * Валидирует промпт на корректность @{...} ссылок - * @param promptContent - содержимое промпта - * @returns результат валидации - */ - static validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - // Ищем шаблонные ссылки @{...} - const templateRegex = /@\{([^}]+)\}/g; - const matches = promptContent.matchAll(templateRegex); - - for (const match of matches) { - const referenceName = match[1].trim(); - - // Проверяем, что имя ссылки не пустое - if (!referenceName) { - errors.push(`Пустая ссылка в позиции ${match.index}`); - continue; - } - - // Проверяем, что имя содержит только допустимые символы - if (!/^[a-zA-Z0-9_-]+$/.test(referenceName)) { - errors.push(`Недопустимые символы в ссылке "${referenceName}"`); - } - } - - return { - valid: errors.length === 0, - errors: errors - }; - } - - /** - * Извлекает все шаблонные ссылки @{...} из промпта - * @param promptContent - содержимое промпта - * @returns массив имен ссылок - */ - static extractTemplateReferences(promptContent: string): string[] { - const references: string[] = []; - const templateRegex = /@\{([^}]+)\}/g; - const matches = promptContent.matchAll(templateRegex); - - for (const match of matches) { - const referenceName = match[1].trim(); - if (referenceName && !references.includes(referenceName)) { - references.push(referenceName); - } - } - - return references; - } -} \ No newline at end of file + /** + * Парсит текст и находит все @/@@ и #/## ссылки + * @param text - текст для парсинга + * @returns массив найденных ссылок + */ + static parseReferences(text: string): ReferenceMatch[] { + const matches: ReferenceMatch[] = []; + + // Регулярное выражение для поиска @/@@ и #/## ссылок + // Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов + const regex = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g; + + let match; + while ((match = regex.exec(text)) !== null) { + const prefix = match[1]; + const quotedName = match[2]; + const unquotedName = match[3]; + const name = quotedName || unquotedName; + + matches.push({ + fullMatch: match[0], + prefix: prefix, + name: name, + startIndex: match.index, + endIndex: match.index + match[0].length, + type: prefix.startsWith('@') ? 'file' : 'prompt' + }); + } + + return matches; + } + + /** + * Проверяет, является ли позиция курсора внутри незавершённой ссылки + * @param text - текст сообщения + * @param cursorPosition - позиция курсора + * @returns информация о текущей ссылке или null + */ + static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null { + // Ищем незавершенные ссылки перед курсором + const beforeCursor = text.substring(0, cursorPosition); + + // Регулярное выражение для поиска начала ссылки без завершения + const incompleteRegex = /(@{1,2}|#{1,2})([^\s@#"]*?)$/; + const match = beforeCursor.match(incompleteRegex); + + if (match) { + const prefix = match[1]; + const partialName = match[2]; + const startIndex = beforeCursor.length - match[0].length; + + return { + fullMatch: match[0], + prefix: prefix, + name: partialName, + startIndex: startIndex, + endIndex: cursorPosition, + type: prefix.startsWith('@') ? 'file' : 'prompt' + }; + } + + return null; + } + + /** + * Валидирует промпт на корректность @{...} ссылок + * @param promptContent - содержимое промпта + * @returns результат валидации + */ + static validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Ищем шаблонные ссылки @{...} + const templateRegex = /@\{([^}]+)\}/g; + const matches = promptContent.matchAll(templateRegex); + + for (const match of matches) { + const referenceName = match[1].trim(); + + // Проверяем, что имя ссылки не пустое + if (!referenceName) { + errors.push(`Пустая ссылка в позиции ${match.index}`); + continue; + } + + // Проверяем, что имя содержит только допустимые символы + if (!/^[a-zA-Z0-9_-]+$/.test(referenceName)) { + errors.push(`Недопустимые символы в ссылке "${referenceName}"`); + } + } + + return { + valid: errors.length === 0, + errors: errors + }; + } + + /** + * Извлекает все шаблонные ссылки из промпта, основываясь на заданных префиксах. + * По умолчанию ищет ссылки с префиксом @{...}. + * @param promptContent - содержимое промпта + * @param prefixes - массив префиксов, по которым нужно искать ссылки (например, ['@'], ['#'], ['@', '#']). По умолчанию ['@']. + * @returns массив имен ссылок + */ + static extractTemplateReferences(promptContent: string, prefixes: Array<'@' | '#'> = ['@']): string[] { + const references: string[] = []; + let prefixRegexPart: string; + + if (prefixes.includes('@') && prefixes.includes('#')) { + prefixRegexPart = '[#@]'; // Ищет как @, так и # + } else if (prefixes.includes('@')) { + prefixRegexPart = '@'; // Ищет только @ + } else if (prefixes.includes('#')) { + prefixRegexPart = '#'; // Ищет только # + } else { + // Если пустой массив или неподдерживаемые префиксы, не ищем ничего + return []; + } + + // Регулярное выражение для поиска ссылок с динамическим префиксом + const templateRegex = new RegExp(`${prefixRegexPart}\\{([^}]+)\\}`, 'g'); + const matches = promptContent.matchAll(templateRegex); + + for (const match of matches) { + const referenceName = match[1].trim(); + if (referenceName && !references.includes(referenceName)) { + references.push(referenceName); + } + } + + return references; + } +} diff --git a/src/references/TemplateEngine.ts b/src/references/TemplateEngine.ts index c0c3465..9940706 100644 --- a/src/references/TemplateEngine.ts +++ b/src/references/TemplateEngine.ts @@ -10,121 +10,124 @@ import { ReferenceParser } from './ReferenceParser'; // ----------------------------------------------- Template Engine --------------------------------------------------------------- export class TemplateEngine { - private plugin: LLMAgentPlugin; + private plugin: LLMAgentPlugin; - constructor(plugin: LLMAgentPlugin) { - this.plugin = plugin; - } + constructor(plugin: LLMAgentPlugin) { + this.plugin = plugin; + } - // ----------------------------------------------- Public Methods --------------------------------------------------------------- + // ----------------------------------------------- Public Methods --------------------------------------------------------------- - /** - * Рекурсивно разворачивает промпт, заменяя @{...} ссылки - * ВАЖНО: НЕ поддерживает рекурсию - только один уровень вложенности - * @param promptContent - содержимое промпта - * @returns развернутый промпт - */ - async expandPromptTemplate(promptContent: string): Promise { - // Находим все шаблонные ссылки @{...} - const templateReferences = ReferenceParser.extractTemplateReferences(promptContent); - - if (templateReferences.length === 0) { - return promptContent; - } - - let expandedContent = promptContent; - - // Заменяем каждую ссылку на содержимое соответствующего промпта - for (const referenceName of templateReferences) { - const referencedContent = await this.loadPromptContent(referenceName); - if (referencedContent !== null) { - // Заменяем все вхождения @{referenceName} на содержимое - const templatePattern = new RegExp(`@\\{${this.escapeRegExp(referenceName)}\\}`, 'g'); - expandedContent = expandedContent.replace(templatePattern, referencedContent); - } else { - // Если промпт не найден, оставляем ссылку как есть - console.warn(`Промпт "${referenceName}" не найден`); - } - } - - return expandedContent; - } + /** + * Рекурсивно разворачивает промпт, заменяя @{...} ссылки. + * Обнаруживает и предотвращает бесконечную рекурсию при циклических ссылках. + * @param promptContent - содержимое промпта + * @param expandedReferences - внутренний Set для отслеживания уже развернутых ссылок в текущей цепочке рекурсии + * @returns развернутый промпт + */ + async expandPromptTemplate(promptContent: string, expandedReferences: Set = new Set()): Promise { + // Находим все шаблонные ссылки @{...} + const templateReferences = ReferenceParser.extractTemplateReferences(promptContent, ['@', '#']); - /** - * Валидирует промпт на корректность @{...} ссылок - * @param promptContent - содержимое промпта - * @returns результат валидации - */ - validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } { - const parseResult = ReferenceParser.validatePromptReferences(promptContent); - const errors = [...parseResult.errors]; - - // Дополнительная проверка - существуют ли referenced промпты - const templateReferences = ReferenceParser.extractTemplateReferences(promptContent); - - for (const referenceName of templateReferences) { - const promptFile = this.findPromptFile(referenceName); - if (!promptFile) { - errors.push(`Промпт "${referenceName}" не найден в папке ${this.plugin.settings.promptsFolder}`); - } - } - - return { - valid: errors.length === 0, - errors: errors - }; - } + if (templateReferences.length === 0) { + return promptContent; + } - // ----------------------------------------------- Private Methods --------------------------------------------------------------- + // Получаем все файлы в хранилище Obsidian один раз для всей цепочки рекурсии + // Это позволяет избежать повторных вызовов vault.getFiles() + const allFiles = this.plugin.app.vault.getFiles(); - /** - * Загружает содержимое промпта по имени - * @param promptName - имя промпта - * @returns содержимое промпта или null если не найден - */ - private async loadPromptContent(promptName: string): Promise { - const promptFile = this.findPromptFile(promptName); - - if (!promptFile) { - return null; - } - - try { - const content = await this.plugin.app.vault.read(promptFile); - return content.trim(); - } catch (error) { - console.error(`Ошибка чтения промпта "${promptName}":`, error); - return null; - } - } + let expandedContent = promptContent; - /** - * Находит файл промпта по имени - * @param promptName - имя промпта - * @returns файл промпта или null - */ - private findPromptFile(promptName: string): TFile | null { - const promptsFolder = this.plugin.settings.promptsFolder; - if (!promptsFolder) { - return null; - } - - // Нормализуем путь к папке промптов - const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, ''); - - // Ищем файл с именем promptName.md в папке промптов - const promptPath = `${normalizedPromptsPath}/${promptName}.md`; - const file = this.plugin.app.vault.getAbstractFileByPath(promptPath); - - return (file instanceof TFile) ? file : null; - } + // Заменяем каждую ссылку на содержимое соответствующего промпта + for (const referenceName of templateReferences) { + if (expandedReferences.has(referenceName)) { + console.warn( + `Обнаружена циклическая ссылка на промпт "${referenceName}". Игнорируем для предотвращения бесконечной рекурсии.` + ); + continue; // Пропускаем циклическую ссылку + } - /** - * Экранирует специальные символы для регулярного выражения - * @param string - строка для экранирования - * @returns экранированная строка - */ - private escapeRegExp(string: string): string { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } -} \ No newline at end of file + // Добавляем текущую ссылку в Set для отслеживания циклов + expandedReferences.add(referenceName); + + const referencedContent = await this.loadPromptContent(referenceName, allFiles); + if (referencedContent !== null) { + // Рекурсивно разворачиваем содержимое вложенного промпта + const expandedNestedContent = await this.expandPromptTemplate(referencedContent, expandedReferences); + + // Заменяем все вхождения @{referenceName} или #{referenceName} на содержимое + const templatePattern = new RegExp(`[#@]?\\{${this.escapeRegExp(referenceName)}\\}`, 'g'); + expandedContent = expandedContent.replace(templatePattern, expandedNestedContent); + } else { + // Если промпт не найден, оставляем ссылку как есть + console.warn(`Промпт "${referenceName}" не найден.`); + } + + // Удаляем текущую ссылку из Set после её обработки, чтобы не мешать другим веткам рекурсии + expandedReferences.delete(referenceName); + } + + return expandedContent; + } + + // ----------------------------------------------- Private Methods --------------------------------------------------------------- + + /** + * Загружает содержимое промпта по имени + * @param promptName - имя промпта + * @returns содержимое промпта или null если не найден + */ + private async loadPromptContent(promptName: string, allFiles: TFile[]): Promise { + const promptFile = this.findPromptFile(promptName, allFiles); + + if (!promptFile) { + return null; + } + + try { + const content = await this.plugin.app.vault.read(promptFile); + return content.trim(); + } catch (error) { + console.error(`Ошибка чтения промпта "${promptName}":`, error); + return null; + } + } + + /** + * Находит файл промпта по имени + * @param promptName - имя промпта + * @returns файл промпта или null + */ + private findPromptFile(promptName: string, allFiles: TFile[]): TFile | null { + /*const promptsFolder = this.plugin.settings.promptsFolder; + if (!promptsFolder) { + return null; + } + + // Нормализуем путь к папке промптов, убирая начальные/конечные слеши + const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, '');*/ + + // Ищем файл, который находится в папке промптов (или ее подпапках), является Markdown-файлом + // и его имя (без расширения) совпадает с promptName + for (const file of allFiles) { + // Проверяем, является ли файл Markdown и находится ли он в папке промптов + // Сравниваем имя файла (без расширения) с искомым именем промпта + // Пока без этого: file.path.startsWith(`${normalizedPromptsPath}/` + if (file.extension === 'md' && file.basename === promptName) { + return file; + } + } + + return null; + } + + /** + * Экранирует специальные символы для регулярного выражения + * @param string - строка для экранирования + * @returns экранированная строка + */ + private escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } +}