brianpetro_smart-context-ob.../smart_context.js
2024-12-25 13:07:17 -05:00

349 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* @file smart_context.js
* @description Refactored SmartContext module that uses a SmartFs instance and a settings object.
*/
import {
strip_excluded_sections,
remove_included_link_lines,
inline_embedded_links,
} from './utils.js';
/**
* @typedef {Object} SmartContextFs
* @property {(filePath: string, encoding?: string) => Promise<string>} read
* @property {(linkText: string, currentFilePath: string) => string|undefined} get_link_target_path
*/
/**
* @typedef {Object} BuildContextOptions
* @property {('folder'|'visible'|'all-open'|'visible-linked'|'all-open-linked')} mode
* @property {string} [label]
* @property {string} [folder_structure]
* @property {{path: string}[]} [files]
* @property {{path: string}[]} [initial_files]
* @property {{path: string}[]} [all_files]
* @property {string} [active_file_path]
* @property {number} [link_depth]
* @property {string} [before_prompt]
* @property {string} [before_each_prompt]
* @property {string} [after_each_prompt]
* @property {string} [after_prompt]
* @property {string[]} [excluded_headings]
*/
/**
* @typedef {Object} SmartContextSettings
* @property {string[]} excluded_headings
* @property {boolean} skip_exclude_links_in_active_file
* @property {string} before_prompt
* @property {string} before_each_prompt
* @property {string} after_each_prompt
* @property {string} after_prompt
* @property {number} link_depth
*/
/**
* A module for building "smart context" from multiple notes.
* This class is library-agnostic: it does not rely on Obsidians APIs directly.
*/
export class SmartContext {
/**
* @param {Object} opts
* @param {SmartContextFs} opts.fs - A SmartFs instance for reading files, resolving links, etc.
* @param {SmartContextSettings} opts.settings - The plugin's settings object.
*/
constructor(opts) {
this.fs = opts.fs;
/** @type {SmartContextSettings} */
this.settings = opts.settings || {
excluded_headings: [],
skip_exclude_links_in_active_file: false,
before_prompt: '',
before_each_prompt: '',
after_each_prompt: '',
after_prompt: '',
link_depth: 0,
};
}
/**
* Build a final object that aggregates the requested files/folders.
*
* Returns:
* {
* context: string,
* stats: {
* file_count: number,
* total_excluded_sections: number,
* excluded_sections_map: Map<string, number>
* }
* }
*
* @param {BuildContextOptions} context_opts
* @returns {Promise<{context: string, stats: { file_count: number, total_excluded_sections: number, excluded_sections_map: Map<string, number>}}>}
*/
async build_context(context_opts) {
const {
mode,
label = '',
folder_structure = '',
files = [],
initial_files = [],
all_files = [],
active_file_path = '',
link_depth = this.settings.link_depth,
// If user passes them directly, override plugin settings. Otherwise, pull from this.settings.
before_prompt = this.settings.before_prompt,
before_each_prompt = this.settings.before_each_prompt,
after_each_prompt = this.settings.after_each_prompt,
after_prompt = this.settings.after_prompt,
} = context_opts;
// We'll use the plugins settings if no per-command overrides
const excluded_headings =
context_opts.hasOwnProperty('excluded_headings')
? context_opts.excluded_headings
: this.settings.excluded_headings;
let content_to_copy = '';
// Stats
let file_count = 0;
let total_excluded_sections = 0;
/** @type {Map<string, number>} */
let excluded_sections_map = new Map();
// Insert top-level "before_prompt"
if (before_prompt) {
content_to_copy += `${before_prompt}\n`;
}
switch (mode) {
case 'folder': {
content_to_copy += `${label}:\n${folder_structure}\nFile contents:\n`;
for (const file of files) {
file_count++;
if (before_each_prompt) content_to_copy += `${before_each_prompt}\n`;
const { processed_content, excluded_count, excluded_sections } = await this.#process_file(
file.path,
excluded_headings
);
total_excluded_sections += excluded_count;
excluded_sections.forEach((cnt, sec) => {
excluded_sections_map.set(sec, (excluded_sections_map.get(sec) || 0) + cnt);
});
content_to_copy += `----------------------\n/${file.path}\n-----------------------\n${processed_content}\n-----------------------\n\n`;
if (after_each_prompt) content_to_copy += `${after_each_prompt}\n`;
}
break;
}
case 'visible':
case 'all-open': {
content_to_copy += `${label}:\n`;
for (const file of files) {
file_count++;
if (before_each_prompt) content_to_copy += `${before_each_prompt}\n`;
const { processed_content, excluded_count, excluded_sections } = await this.#process_file(
file.path,
excluded_headings
);
total_excluded_sections += excluded_count;
excluded_sections.forEach((cnt, sec) => {
excluded_sections_map.set(sec, (excluded_sections_map.get(sec) || 0) + cnt);
});
content_to_copy += `----------------------\n/${file.path}\n-----------------------\n${processed_content}\n-----------------------\n\n`;
if (after_each_prompt) content_to_copy += `${after_each_prompt}\n`;
}
break;
}
case 'visible-linked':
case 'all-open-linked': {
// “linked_only” = in all_files but not in initial_files
const initPaths = new Set(initial_files.map((f) => f.path));
const linked_only = all_files.filter((f) => !initPaths.has(f.path));
// Process the linked-only files first
if (linked_only.length > 0) {
content_to_copy += `Linked files:\n`;
for (const lf of linked_only) {
file_count++;
if (before_each_prompt) content_to_copy += `${before_each_prompt}\n`;
const { processed_content, excluded_count, excluded_sections } = await this.#process_file(
lf.path,
excluded_headings
);
total_excluded_sections += excluded_count;
excluded_sections.forEach((cnt, sec) => {
excluded_sections_map.set(sec, (excluded_sections_map.get(sec) || 0) + cnt);
});
content_to_copy += `----------------------\n/${lf.path}\n-----------------------\n${processed_content}\n\n-----------------------\n\n`;
if (after_each_prompt) content_to_copy += `${after_each_prompt}\n`;
}
}
// Then process the initial (visible/all-open) files
content_to_copy += `\n${label}:\n`;
for (const file of initial_files) {
file_count++;
if (before_each_prompt) content_to_copy += `${before_each_prompt}\n`;
const skipLinkRemoval =
this.settings.skip_exclude_links_in_active_file && file.path === active_file_path;
const { processed_content, excluded_count, excluded_sections } = await this.#process_file_inlined_embeds(
file.path,
excluded_headings,
initPaths,
skipLinkRemoval
);
total_excluded_sections += excluded_count;
excluded_sections.forEach((cnt, sec) => {
excluded_sections_map.set(sec, (excluded_sections_map.get(sec) || 0) + cnt);
});
content_to_copy += `----------------------\n/${file.path}\n-----------------------\n${processed_content}\n`;
if (after_each_prompt) content_to_copy += `${after_each_prompt}\n`;
}
break;
}
default: {
// Fallback if an unknown mode is passed
content_to_copy += '(No valid mode selected.)\n';
break;
}
}
// Insert bottom-level "after_prompt"
if (after_prompt) {
content_to_copy += `\n${after_prompt}\n`;
}
return {
context: content_to_copy,
stats: {
file_count,
total_excluded_sections,
excluded_sections_map,
},
};
}
/**
* Basic file read + heading exclusion only.
* @private
*/
async #process_file(filePath, excluded_headings) {
let raw = '';
try {
raw = await this.fs.read(filePath, 'utf-8');
} catch (e) {
console.warn(`Could not read file: ${filePath}`, e);
}
const { processed_content, excluded_count, excluded_sections } = strip_excluded_sections(
raw,
excluded_headings
);
return { processed_content, excluded_count, excluded_sections };
}
/**
* Read + inline embedded links + remove link-only lines (unless skipping) + exclude headings.
* @private
*/
async #process_file_inlined_embeds(filePath, excluded_headings, initPaths, skipLinkRemoval) {
let raw = '';
try {
raw = await this.fs.read(filePath, 'utf-8');
} catch (e) {
console.warn(`Could not read file: ${filePath}`, e);
}
// Inline any ![[embeds]]
raw = await inline_embedded_links(
raw,
filePath,
(linkText, curPath) => this.fs.get_link_target_path(linkText, curPath),
async (fp) => {
try {
return await this.fs.read(fp, 'utf-8');
} catch {
return '';
}
},
excluded_headings,
() => new Set() // no “current-embeds” knowledge by default; we could expand if needed
);
// Remove lines that are exclusively a single included link
if (!skipLinkRemoval) {
raw = remove_included_link_lines(raw, initPaths, (linkText) =>
this.fs.get_link_target_path(linkText, filePath)
);
}
// finally exclude headings
const { processed_content, excluded_count, excluded_sections } = strip_excluded_sections(
raw,
excluded_headings
);
return { processed_content, excluded_count, excluded_sections };
}
/**
* Expose a settings config object for use in SmartView or manual rendering.
* Should use sentence case for the names (Obsidian requires sentence case).
*/
get settings_config() {
return {
excluded_headings: {
name: 'Excluded headings',
description: 'Headings to exclude from copied content (one per line).',
type: 'textarea',
},
skip_exclude_links_in_active_file: {
name: 'Skip link-only removal in active note',
description: 'If ON, do not remove lines that are only links in the active note.',
type: 'toggle',
},
include_file_tree: {
name: 'Include file tree',
description: 'If ON, include a file tree in the output.',
type: 'toggle',
},
link_depth: {
name: 'Link depth',
description: 'Number of link “hops” to follow for “with linked” commands (0 = no links).',
type: 'number',
},
before_prompt: {
name: 'Before prompt (once)',
description: 'Text inserted at the top of the output.',
type: 'textarea',
},
before_each_prompt: {
name: 'Before each file',
description: 'Text inserted before each files content.',
type: 'textarea',
},
after_each_prompt: {
name: 'After each file',
description: 'Text inserted after each files content.',
type: 'textarea',
},
after_prompt: {
name: 'After prompt (once)',
description: 'Text inserted at the very bottom of the output.',
type: 'textarea',
},
};
}
}