aaronsb_obsidian-mcp-plugin/src/utils/obsidian-api.ts
Aaron Bockelie f618cffcca chore(lint): support eslint-plugin-obsidianmd@0.3.0
The 0.3.0 release adds typed rules (no-plugin-as-component, no-view-references-in-plugin,
no-unsupported-api, prefer-file-manager-trash-file, prefer-instanceof) and wires
them into the hybrid recommended config without a **/*.ts files scope. As a
result they also try to run against package.json — which uses ESLint's JSON
language and has no @typescript-eslint parser services — and ESLint aborts
the whole run with "Error while loading rule 'obsidianmd/no-plugin-as-component'".

- eslint.config.mts: disable those five typed rules on JSON files, and
  ignore the data files the recommended config doesn't wire a JSON parser
  for (tsconfig.json, package-lock.json, manifest.json, versions.json,
  src/config/**/*.json, .claude/**).
- Keep the autofix for `setTimeout` → `window.setTimeout` and
  `globalThis` → `window` in code paths that only run inside Obsidian
  (main.ts, main-complex.ts, worker-manager.ts, connection-pool.ts,
  tools/fetch.ts) — that's the popout-window-compat behaviour the rule
  exists to enforce.
- Revert the autofix in code paths imported by Jest tests where `window`
  is undefined: session-manager.ts and obsidian-api.ts background timers,
  semantic/router.ts active-file timeout, debug.ts console reference.
  Each retains a `// eslint-disable-next-line` with the reason
  (background timer / Node test compat).
2026-05-15 15:01:03 -05:00

1010 lines
No EOL
32 KiB
TypeScript

import { App, TFile, TFolder, TAbstractFile, Command, getAllTags } from 'obsidian';
import { ObsidianConfig, ObsidianFile, ObsidianFileResponse } from '../types/obsidian';
import { paginateFiles } from './response-limiter';
import { isImageFile as checkIsImageFile, processImageResponse, IMAGE_PROCESSING_PRESETS } from './image-handler';
import { getVersion } from '../version';
import { SearchResult } from './advanced-search';
import { SearchFacade } from './search-facade';
import { MCPIgnoreManager } from '../security/mcp-ignore-manager';
import { Debug } from './debug';
import { BasesAPI } from './bases-api';
import { BaseYAML, BaseQueryResult as BasesQueryResult } from '../types/bases-yaml';
import { InputValidator, ValidationException, ValidationConfig } from '../validation/input-validator';
/** MCP server info used by ObsidianAPI */
interface ObsidianAPIMCPServerInfo {
isServerRunning(): boolean;
getConnectionCount(): number;
}
/** Minimal plugin interface for ObsidianAPI dependency */
export interface ObsidianAPIPluginRef {
settings?: {
validation?: Partial<ValidationConfig>;
httpEnabled?: boolean;
httpsEnabled?: boolean;
httpPort?: number;
httpsPort?: number;
};
ignoreManager?: MCPIgnoreManager;
mcpServer?: ObsidianAPIMCPServerInfo;
manifest?: { dir?: string };
}
/** Internal Obsidian App interface exposing appVersion and commands */
interface AppInternal extends App {
appVersion?: string;
commands?: {
commands?: Record<string, ObsidianCommand>;
executeCommandById?(id: string): boolean;
};
}
/** Internal Obsidian command structure */
interface ObsidianCommand {
id: string;
name: string;
icon?: string;
}
/** Structured patch parameters for vault file operations */
export interface PatchParams {
targetType?: string;
target?: string;
operation?: string;
content?: string;
old_text?: string;
new_text?: string;
position?: number;
text?: string;
start?: number;
end?: number;
}
/** File listing detail object */
interface FileDetailObject {
path: string;
name: string;
type: 'file' | 'folder';
size?: number;
extension?: string;
modified?: number;
}
export class ObsidianAPI {
private app: App;
private config: ObsidianConfig;
private plugin?: ObsidianAPIPluginRef; // Reference to the plugin for accessing MCP server info
private ignoreManager?: MCPIgnoreManager;
private basesAPI: BasesAPI;
private validator: InputValidator;
private searchFacade: SearchFacade;
constructor(app: App, config?: ObsidianConfig, plugin?: ObsidianAPIPluginRef) {
this.app = app;
this.config = config || { apiKey: '', apiUrl: '' };
this.plugin = plugin;
this.ignoreManager = plugin?.ignoreManager;
this.basesAPI = new BasesAPI(app);
this.searchFacade = new SearchFacade(app);
// Initialize input validator with plugin settings or defaults
const validationSettings: Partial<ValidationConfig> = plugin?.settings?.validation ?? {};
this.validator = new InputValidator(validationSettings);
Debug.log(`ObsidianAPI initialized with ignoreManager: ${!!this.ignoreManager}, enabled: ${this.ignoreManager?.getEnabled()}`);
}
// Getter to access the App instance for graph operations
getApp(): App {
return this.app;
}
// Server info
getServerInfo() {
const baseInfo = {
authenticated: true,
cors: true,
ok: true,
service: 'Obsidian MCP Plugin',
versions: {
obsidian: (this.app as unknown as AppInternal).appVersion || '1.0.0',
'self': getVersion()
}
};
// Read Daily Notes plugin config
const dailyNotesFolder = this.getDailyNotesFolder();
// Add MCP server connection info if plugin is available
if (this.plugin?.mcpServer) {
const mcpServer = this.plugin.mcpServer;
const pluginSettings = this.plugin.settings;
return {
...baseInfo,
mcp: {
running: mcpServer.isServerRunning(),
httpEnabled: pluginSettings?.httpEnabled,
httpsEnabled: pluginSettings?.httpsEnabled,
httpPort: pluginSettings?.httpPort,
httpsPort: pluginSettings?.httpsPort,
connections: mcpServer.getConnectionCount() || 0,
vault: this.app.vault.getName()
},
...(dailyNotesFolder !== undefined && { dailyNotesFolder })
};
}
return { ...baseInfo, ...(dailyNotesFolder !== undefined && { dailyNotesFolder }) };
}
/**
* Get the configured Daily Notes folder from Obsidian's internal plugin.
*/
private getDailyNotesFolder(): string | undefined {
try {
const internalPlugins = (this.app as unknown as Record<string, unknown>).internalPlugins as
{ getPluginById(id: string): { enabled: boolean; instance?: { options?: { folder?: string } } } | null } | undefined;
if (!internalPlugins) return undefined;
const dailyNotes = internalPlugins.getPluginById('daily-notes');
if (dailyNotes?.enabled && dailyNotes.instance?.options?.folder) {
return dailyNotes.instance.options.folder;
}
} catch {
// Internal plugin API not available
}
return undefined;
}
// Active file operations
async getActiveFile(): Promise<ObsidianFile> {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
throw new Error('No active file');
}
const content = await this.app.vault.read(activeFile);
// Extract metadata from cache
const cache = this.app.metadataCache.getFileCache(activeFile);
const tags = cache ? (getAllTags(cache) || []) : [];
const frontmatter = cache?.frontmatter ? { ...cache.frontmatter } : {};
// Remove position metadata from frontmatter (internal Obsidian data)
if (frontmatter.position) {
delete frontmatter.position;
}
return {
path: activeFile.path,
content,
tags,
frontmatter
};
}
async updateActiveFile(content: string) {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
throw new Error('No active file');
}
await this.app.vault.modify(activeFile, content);
return { success: true };
}
async appendToActiveFile(content: string) {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
throw new Error('No active file');
}
const existingContent = await this.app.vault.read(activeFile);
await this.app.vault.modify(activeFile, existingContent + content);
return { success: true };
}
async deleteActiveFile() {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
throw new Error('No active file');
}
await this.app.fileManager.trashFile(activeFile);
return { success: true };
}
async patchActiveFile(params: PatchParams) {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
throw new Error('No active file');
}
return await this.patchVaultFile(activeFile.path, params);
}
// Vault file operations
listFiles(directory?: string): Promise<string[]> {
const vault = this.app.vault;
let files: TAbstractFile[];
if (directory && directory !== '/') {
const folder = vault.getAbstractFileByPath(directory);
if (!folder || !(folder instanceof TFolder)) {
throw new Error(`Directory not found: ${directory}`);
}
// Walk the folder tree to collect every descendant. Matches the
// recursive semantics of vault.getAllLoadedFiles() for the root
// case, so a caller doesn't see an empty list when a folder only
// contains subfolders.
files = [];
const stack: TAbstractFile[] = [...folder.children];
while (stack.length > 0) {
const f = stack.pop()!;
files.push(f);
if (f instanceof TFolder) {
stack.push(...f.children);
}
}
} else {
files = vault.getAllLoadedFiles();
}
// Return file paths, filtering out folders and excluded paths
const filePaths = files
.filter(file => file instanceof TFile)
.map(file => file.path)
.sort();
// Filter out excluded paths
const result = this.ignoreManager ? this.ignoreManager.filterPaths(filePaths) : filePaths;
return Promise.resolve(result);
}
listFilesPaginated(
directory?: string,
page: number = 1,
pageSize: number = 20,
recursive: boolean = false
): Promise<{
files: Array<{
path: string;
name: string;
type: 'file' | 'folder';
size?: number;
extension?: string;
modified?: number;
}>;
page: number;
pageSize: number;
totalFiles: number;
totalPages: number;
directory?: string;
}> {
const vault = this.app.vault;
let files: TAbstractFile[];
if (directory && directory !== '/') {
const folder = vault.getAbstractFileByPath(directory);
if (!folder || !(folder instanceof TFolder)) {
throw new Error(`Directory not found: ${directory}`);
}
if (recursive) {
// Walk the tree and keep only files — pagination is over the same
// flat universe listFiles() returns, so an agent paging through a
// directory sees a coherent, consistent set of items.
files = [];
const stack: TAbstractFile[] = [...folder.children];
while (stack.length > 0) {
const f = stack.pop()!;
if (f instanceof TFile) {
files.push(f);
} else if (f instanceof TFolder) {
stack.push(...f.children);
}
}
} else {
files = folder.children;
}
} else {
files = vault.getAllLoadedFiles();
}
// Create detailed file objects
const fileObjects: FileDetailObject[] = files.map(file => {
const isFile = file instanceof TFile;
const result: FileDetailObject = {
path: file.path,
name: file.name,
type: isFile ? 'file' : 'folder'
};
if (isFile) {
result.size = file.stat.size;
result.extension = file.extension;
result.modified = file.stat.mtime;
}
return result;
}).sort((a: FileDetailObject, b: FileDetailObject) => {
if (recursive) {
// Match listFiles() — full-path lexicographic. The agent's
// "use page=N" hint anchors to the same total order, so page
// boundaries are stable across calls and basename collisions
// (e.g. two index.md in different subfolders) sort deterministically.
return a.path.localeCompare(b.path);
}
// Level-only mode: folders first, then files by basename — what
// existing internal callers (cp recursion, isDirectory probe)
// depend on for tree navigation.
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
return Promise.resolve(paginateFiles(fileObjects, page, pageSize, directory));
}
async getFile(path: string): Promise<ObsidianFileResponse> {
// Check if path is excluded
if (this.ignoreManager && this.ignoreManager.isExcluded(path)) {
throw new Error(`File not found: ${path}`);
}
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
throw new Error(`File not found: ${path}`);
}
// Check if it's an image file
if (checkIsImageFile(path)) {
const arrayBuffer = await this.app.vault.readBinary(file);
return await processImageResponse(path, arrayBuffer, IMAGE_PROCESSING_PRESETS.none);
}
// Regular text file
const content = await this.app.vault.read(file);
// Extract metadata from cache
const cache = this.app.metadataCache.getFileCache(file);
const tags = cache ? (getAllTags(cache) || []) : [];
const frontmatter = cache?.frontmatter ? { ...cache.frontmatter } : {};
// Remove position metadata from frontmatter (internal Obsidian data)
if (frontmatter.position) {
delete frontmatter.position;
}
return {
path: file.path,
content,
tags,
frontmatter
};
}
async createFile(path: string, content: string) {
// Validate input
const validationResult = this.validator.validate('file.create', { path, content });
if (!validationResult.valid) {
throw new ValidationException(
validationResult.errors || [],
`Validation failed for createFile: ${validationResult.errors?.map(e => e.message).join(', ')}`
);
}
// Check if path is excluded
if (this.ignoreManager && this.ignoreManager.isExcluded(path)) {
throw new Error(`Access denied: ${path}`);
}
// Ensure directory exists
const dirPath = path.substring(0, path.lastIndexOf('/'));
if (dirPath && !this.app.vault.getAbstractFileByPath(dirPath)) {
await this.ensureDirectoryExists(dirPath);
}
const result = await this.withVaultRetry(
async () => {
const file = await this.app.vault.create(path, content);
return {
success: true,
path: file.path,
name: file.name
};
},
'file creation',
500 // Base delay for file operations
);
return result;
}
async updateFile(path: string, content: string) {
// Validate input
const validationResult = this.validator.validate('file.update', { path, content });
if (!validationResult.valid) {
throw new ValidationException(
validationResult.errors || [],
`Validation failed for updateFile: ${validationResult.errors?.map(e => e.message).join(', ')}`
);
}
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
throw new Error(`File not found: ${path}`);
}
await this.app.vault.modify(file, content);
return { success: true };
}
async deleteFile(path: string) {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file) {
throw new Error(`File not found: ${path}`);
}
await this.app.fileManager.trashFile(file);
return { success: true };
}
async appendToFile(path: string, content: string) {
// Validate input
const validationResult = this.validator.validate('file.append', { content });
if (!validationResult.valid) {
throw new ValidationException(
validationResult.errors || [],
`Validation failed for appendToFile: ${validationResult.errors?.map(e => e.message).join(', ')}`
);
}
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
throw new Error(`File not found: ${path}`);
}
const existingContent = await this.app.vault.read(file);
// Validate combined content size
const combinedValidation = this.validator.validate('file.append', { content: existingContent + content });
if (!combinedValidation.valid) {
throw new ValidationException(
combinedValidation.errors || [],
`Validation failed: Combined file size would exceed limit`
);
}
await this.app.vault.modify(file, existingContent + content);
return { success: true };
}
async patchVaultFile(path: string, params: PatchParams) {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
throw new Error(`File not found: ${path}`);
}
let content = await this.app.vault.read(file);
// Handle structured targeting (heading, block, frontmatter)
if (params.targetType && params.target) {
content = this.applyStructuredPatch(content, params);
}
// Handle legacy patch operations
else if (params.operation === 'replace') {
if (params.old_text && params.new_text) {
content = content.replace(params.old_text, params.new_text);
}
} else if (params.operation === 'insert') {
if (params.position !== undefined) {
content = content.slice(0, params.position) + (params.text ?? '') + content.slice(params.position);
}
} else if (params.operation === 'delete') {
if (params.start !== undefined && params.end !== undefined) {
content = content.slice(0, params.start) + content.slice(params.end);
}
}
await this.app.vault.modify(file, content);
return { success: true, updated_content: content };
}
private applyStructuredPatch(content: string, params: PatchParams): string {
const { targetType, target, operation, content: patchContent } = params;
switch (targetType) {
case 'heading':
return this.patchHeading(content, target ?? '', operation ?? '', patchContent ?? '');
case 'block':
return this.patchBlock(content, target ?? '', operation ?? '', patchContent ?? '');
case 'frontmatter':
return this.patchFrontmatter(content, target ?? '', operation ?? '', patchContent ?? '');
default:
throw new Error(`Unknown targetType: ${String(targetType)}`);
}
}
private patchHeading(content: string, headingPath: string, operation: string, patchContent: string): string {
const lines = content.split('\n');
const headingHierarchy = headingPath.split('::').map(h => h.trim());
// Find the target heading
let currentLevel = 0;
let targetLineIndex = -1;
let endLineIndex = -1;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
const headingText = headingMatch[2].trim();
// Check if we're at the right level in hierarchy
if (currentLevel < headingHierarchy.length &&
headingText === headingHierarchy[currentLevel]) {
currentLevel++;
if (currentLevel === headingHierarchy.length) {
targetLineIndex = i;
// Find where this section ends
for (let j = i + 1; j < lines.length; j++) {
const nextHeadingMatch = lines[j].match(/^(#{1,6})\s+/);
if (nextHeadingMatch && nextHeadingMatch[1].length <= level) {
endLineIndex = j;
break;
}
}
if (endLineIndex === -1) {
endLineIndex = lines.length;
}
break;
}
} else if (level <= currentLevel) {
// Reset if we've moved to a different section
currentLevel = 0;
}
}
}
if (targetLineIndex === -1) {
throw new Error(`Heading not found: ${headingPath}`);
}
// Apply the operation
switch (operation) {
case 'append': {
// Add content at the end of the section
// Fix for list continuity - thanks to @that0n3guy (PR #44)
const lastLine = endLineIndex > 0 ? lines[endLineIndex - 1] : '';
const isLastLineEmpty = lastLine.trim() === '';
const listRegex = /^(\s*)([-*+]|\d+\.)\s+/;
const isPatchList = listRegex.test(patchContent);
// Find the last non-empty line to check if it's a list
let lastNonEmptyLine = '';
for (let i = endLineIndex - 1; i >= targetLineIndex + 1; i--) {
if (lines[i].trim() !== '') {
lastNonEmptyLine = lines[i];
break;
}
}
const isLastNonEmptyLineList = listRegex.test(lastNonEmptyLine);
if (isLastLineEmpty && isLastNonEmptyLineList && isPatchList) {
// Preserve list continuity by replacing empty line
lines.splice(endLineIndex - 1, 1, patchContent);
} else if (!isLastLineEmpty && isLastNonEmptyLineList && isPatchList) {
// Append list item without blank line
lines.splice(endLineIndex, 0, patchContent);
} else {
// Default: add blank line separator (original behavior)
lines.splice(endLineIndex, 0, '', patchContent);
}
break;
}
case 'prepend':
// Add content right after the heading
lines.splice(targetLineIndex + 1, 0, '', patchContent);
break;
case 'replace': {
// Replace the entire section content (keeping the heading)
const sectionLines = endLineIndex - targetLineIndex - 1;
lines.splice(targetLineIndex + 1, sectionLines, '', patchContent);
break;
}
}
return lines.join('\n');
}
private patchBlock(content: string, blockId: string, operation: string, patchContent: string): string {
const lines = content.split('\n');
let blockLineIndex = -1;
// Find the block by ID (blocks end with ^blockId)
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim().endsWith(`^${blockId}`)) {
blockLineIndex = i;
break;
}
}
if (blockLineIndex === -1) {
throw new Error(`Block not found: ^${blockId}`);
}
// Apply the operation
switch (operation) {
case 'append':
lines[blockLineIndex] = lines[blockLineIndex].replace(`^${blockId}`, `${patchContent} ^${blockId}`);
break;
case 'prepend': {
const blockContent = lines[blockLineIndex].replace(`^${blockId}`, '').trim();
lines[blockLineIndex] = `${patchContent} ${blockContent} ^${blockId}`;
break;
}
case 'replace':
lines[blockLineIndex] = `${patchContent} ^${blockId}`;
break;
}
return lines.join('\n');
}
private patchFrontmatter(content: string, field: string, operation: string, patchContent: string): string {
const lines = content.split('\n');
let inFrontmatter = false;
let frontmatterStart = -1;
let frontmatterEnd = -1;
// Find frontmatter boundaries
for (let i = 0; i < lines.length; i++) {
if (lines[i].trim() === '---') {
if (!inFrontmatter) {
inFrontmatter = true;
frontmatterStart = i;
} else {
frontmatterEnd = i;
break;
}
}
}
// If no frontmatter exists, create it
if (frontmatterStart === -1) {
lines.unshift('---', `${field}: ${patchContent}`, '---', '');
return lines.join('\n');
}
// Find the field in frontmatter
let fieldLineIndex = -1;
for (let i = frontmatterStart + 1; i < frontmatterEnd; i++) {
if (lines[i].startsWith(`${field}:`)) {
fieldLineIndex = i;
break;
}
}
switch (operation) {
case 'append':
if (fieldLineIndex !== -1) {
const currentValue = lines[fieldLineIndex].substring(field.length + 1).trim();
lines[fieldLineIndex] = `${field}: ${currentValue} ${patchContent}`;
} else {
lines.splice(frontmatterEnd, 0, `${field}: ${patchContent}`);
}
break;
case 'prepend':
if (fieldLineIndex !== -1) {
const currentValue = lines[fieldLineIndex].substring(field.length + 1).trim();
lines[fieldLineIndex] = `${field}: ${patchContent} ${currentValue}`;
} else {
lines.splice(frontmatterEnd, 0, `${field}: ${patchContent}`);
}
break;
case 'replace':
if (fieldLineIndex !== -1) {
lines[fieldLineIndex] = `${field}: ${patchContent}`;
} else {
lines.splice(frontmatterEnd, 0, `${field}: ${patchContent}`);
}
break;
}
return lines.join('\n');
}
/**
* Check if a file is readable as text (not binary)
*/
private isTextFile(file: TFile): boolean {
const textExtensions = new Set([
'md', 'txt', 'json', 'js', 'ts', 'css', 'html', 'xml', 'yaml', 'yml',
'csv', 'log', 'py', 'java', 'cpp', 'c', 'h', 'php', 'rb', 'go', 'rs',
'sql', 'sh', 'bat', 'ps1', 'ini', 'conf', 'config', 'env'
]);
return textExtensions.has(file.extension.toLowerCase());
}
// Search operations
async searchPaginated(
query: string,
page: number = 1,
pageSize: number = 10,
strategy: 'filename' | 'content' | 'combined' = 'combined',
includeContent: boolean = true,
options?: { ranked?: boolean; includeSnippets?: boolean; snippetLength?: number }
): Promise<{
query: string;
page: number;
pageSize: number;
totalResults: number;
totalPages: number;
results: SearchResult[];
method: string;
truncated?: boolean;
originalCount?: number;
message?: string;
workflow?: {
message: string;
suggested_next: Array<{
description: string;
command: string;
reason: string;
}>;
};
}> {
// Validate search query
const validationResult = this.validator.validate('search.query', { query });
if (!validationResult.valid) {
throw new ValidationException(
validationResult.errors || [],
`Validation failed for search: ${validationResult.errors?.map(e => e.message).join(', ')}`
);
}
// Delegate to SearchFacade for all search operations
const facadeResponse = await this.searchFacade.searchPaginated(query, {
page,
pageSize,
strategy: strategy as 'filename' | 'content' | 'combined' | 'auto',
includeSnippets: options?.includeSnippets ?? includeContent,
snippetLength: options?.snippetLength,
ranked: options?.ranked
});
// Apply ignore filtering to results (security concern)
const filteredResults = this.ignoreManager
? facadeResponse.results.filter(r => !this.ignoreManager!.isExcluded(r.path))
: facadeResponse.results;
// Convert to SearchResult format and build response
const response: {
query: string;
page: number;
pageSize: number;
totalResults: number;
totalPages: number;
results: SearchResult[];
method: string;
workflow?: {
message: string;
suggested_next: Array<{
description: string;
command: string;
reason: string;
}>;
};
} = {
query: facadeResponse.query,
page: facadeResponse.page,
pageSize: facadeResponse.pageSize,
totalResults: facadeResponse.totalResults,
totalPages: facadeResponse.totalPages,
results: filteredResults.map(r => ({
path: r.path,
title: r.title,
score: r.score,
snippet: r.snippet,
metadata: r.metadata
})),
method: facadeResponse.method
};
Debug.log(`Search found ${response.totalResults} results for query: ${query}`);
if (response.results.length > 0) {
Debug.log('First few results:', response.results.slice(0, 3).map(r => ({ path: r.path, score: r.score })));
}
// Add workflow hints if results were found
if (response.results.length > 0) {
const suggestions = [
{
description: 'View a specific file',
command: 'view:file',
reason: 'To see the full content of a file'
},
{
description: 'Read file fragments',
command: 'vault:fragments',
reason: 'To get relevant excerpts from large files'
},
{
description: 'Edit a file',
command: 'edit:window',
reason: 'To modify content in text files'
}
];
// Add pagination suggestion only for first few pages
if (response.page < response.totalPages && response.page <= 3) {
suggestions.push({
description: 'Get next page of results',
command: 'vault:search',
reason: `View page ${response.page + 1} of ${response.totalPages} (use page: ${response.page + 1})`
});
}
response.workflow = {
message: `Found ${response.totalResults} results${response.totalPages > 1 ? ` (page ${response.page} of ${response.totalPages})` : ''}. You can read, view, or edit these files.`,
suggested_next: suggestions
};
}
return response;
}
// Obsidian integration
async openFile(path: string) {
const file = this.app.vault.getAbstractFileByPath(path);
if (!file || !(file instanceof TFile)) {
throw new Error(`File not found: ${path}`);
}
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
return { success: true };
}
getCommands(): Command[] {
const appInternal = this.app as unknown as AppInternal;
const commands = appInternal.commands?.commands;
if (!commands) {
return [];
}
return Object.values(commands).map((cmd: ObsidianCommand) => ({
id: cmd.id,
name: cmd.name,
icon: cmd.icon
}));
}
executeCommand(commandId: string) {
const appInternal = this.app as unknown as AppInternal;
const success = appInternal.commands?.executeCommandById?.(commandId);
return {
success: !!success,
commandId
};
}
// Helper methods
private async ensureDirectoryExists(dirPath: string) {
const parts = dirPath.split('/').filter(part => part);
let currentPath = '';
for (const part of parts) {
currentPath = currentPath ? `${currentPath}/${part}` : part;
if (!this.app.vault.getAbstractFileByPath(currentPath)) {
await this.createFolderWithRetry(currentPath);
}
}
}
private async createFolderWithRetry(folderPath: string): Promise<void> {
await this.withVaultRetry(
async () => {
await this.app.vault.createFolder(folderPath);
},
'folder creation',
300 // Base delay for folder operations
);
}
/**
* Universal retry mechanism for Vault operations that may conflict with sync processes
* Handles iCloud Drive, OneDrive, Dropbox, and other sync service timing issues
*
* @param operation - Async function to execute with retry logic
* @param operationType - Human-readable description for logging
* @param baseDelayMs - Base delay in milliseconds (exponentially increased per retry)
* @param maxRetries - Maximum number of retry attempts
* @returns Result of the operation
*/
private async withVaultRetry<T>(
operation: () => Promise<T>,
operationType: string,
baseDelayMs: number = 500,
maxRetries: number = 3
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await operation();
} catch (error: unknown) {
// Check if this is a sync-related conflict error
const errorMessage = error instanceof Error ? error.message : String(error);
const isSyncConflictError = errorMessage && (
errorMessage.includes('already exists') ||
errorMessage.includes('file exists') ||
errorMessage.includes('folder exists') ||
errorMessage.includes('EEXIST') ||
errorMessage.includes('ENOENT') || // File disappeared during sync
errorMessage.includes('EBUSY') || // File locked by sync process
errorMessage.includes('EPERM') // Permission denied during sync
);
if (isSyncConflictError && attempt < maxRetries - 1) {
// Exponential backoff: allow time for sync processes to stabilize
const delay = Math.pow(2, attempt) * baseDelayMs;
Debug.log(`${operationType} failed (attempt ${attempt + 1}/${maxRetries}), retrying in ${delay}ms... Error: ${errorMessage}`);
// Backoff delay; must also work in the Jest Node test env.
// eslint-disable-next-line obsidianmd/prefer-window-timers
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
// If it's the final attempt or not a sync-related error, re-throw
throw error;
}
}
// This should never be reached due to the loop logic, but TypeScript needs it
throw new Error(`Failed ${operationType} after ${maxRetries} attempts`);
}
// ============================================
// Bases API Methods
// ============================================
/**
* List all bases in the vault
*/
async listBases(): Promise<Array<{ path: string; name: string; views: string[] }>> {
return await this.basesAPI.listBases();
}
/**
* Read a base configuration
*/
async readBase(path: string): Promise<BaseYAML> {
return await this.basesAPI.readBase(path);
}
/**
* Create a new base
*/
async createBase(path: string, config: BaseYAML): Promise<void> {
return await this.basesAPI.createBase(path, config);
}
/**
* Query a base with optional view
*/
async queryBase(path: string, viewName?: string): Promise<BasesQueryResult> {
return await this.basesAPI.queryBase(path, viewName);
}
/**
* Export base data
*/
async exportBase(path: string, format: 'csv' | 'json' | 'markdown', viewName?: string): Promise<string> {
return await this.basesAPI.exportBase(path, format, viewName);
}
}