collections.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. /**
  2. * Collections configuration management
  3. *
  4. * This module manages the YAML-based collection configuration at ~/.config/qmd/index.yml.
  5. * Collections define which directories to index and their associated contexts.
  6. */
  7. import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
  8. import { join, dirname } from "path";
  9. import { homedir } from "os";
  10. import YAML from "yaml";
  11. // ============================================================================
  12. // Types
  13. // ============================================================================
  14. /**
  15. * Context definitions for a collection
  16. * Key is path prefix (e.g., "/", "/2024", "/Board of Directors")
  17. * Value is the context description
  18. */
  19. export type ContextMap = Record<string, string>;
  20. /**
  21. * A single collection configuration
  22. */
  23. export interface Collection {
  24. path: string; // Absolute path to index
  25. pattern: string; // Glob pattern (e.g., "**/*.md")
  26. ignore?: string[]; // Glob patterns to exclude (e.g., ["Sessions/**"])
  27. context?: ContextMap; // Optional context definitions
  28. update?: string; // Optional bash command to run during qmd update
  29. includeByDefault?: boolean; // Include in queries by default (default: true)
  30. }
  31. /**
  32. * Model configuration for embedding, reranking, and generation
  33. */
  34. export interface ModelsConfig {
  35. embed?: string;
  36. rerank?: string;
  37. generate?: string;
  38. }
  39. /**
  40. * The complete configuration file structure
  41. */
  42. export interface CollectionConfig {
  43. global_context?: string; // Context applied to all collections
  44. editor_uri?: string; // Editor URI template for terminal hyperlinks
  45. editor_uri_template?: string; // Alias for editor_uri
  46. collections: Record<string, Collection>; // Collection name -> config
  47. models?: ModelsConfig;
  48. }
  49. /**
  50. * Collection with its name (for return values)
  51. */
  52. export interface NamedCollection extends Collection {
  53. name: string;
  54. }
  55. // ============================================================================
  56. // Configuration paths
  57. // ============================================================================
  58. // Current index name (default: "index")
  59. let currentIndexName: string = "index";
  60. // SDK mode: optional in-memory config or custom config path
  61. let configSource: { type: 'file'; path?: string } | { type: 'inline'; config: CollectionConfig } = { type: 'file' };
  62. /**
  63. * Set the config source for SDK mode.
  64. * - File path: load/save from a specific YAML file
  65. * - Inline config: use an in-memory CollectionConfig (saveConfig updates in place, no file I/O)
  66. * - undefined: reset to default file-based config
  67. */
  68. export function setConfigSource(source?: { configPath?: string; config?: CollectionConfig }): void {
  69. if (!source) {
  70. configSource = { type: 'file' };
  71. return;
  72. }
  73. if (source.config) {
  74. // Ensure collections object exists
  75. if (!source.config.collections) {
  76. source.config.collections = {};
  77. }
  78. configSource = { type: 'inline', config: source.config };
  79. } else if (source.configPath) {
  80. configSource = { type: 'file', path: source.configPath };
  81. } else {
  82. configSource = { type: 'file' };
  83. }
  84. }
  85. /**
  86. * Set the current index name for config file lookup
  87. * Config file will be ~/.config/qmd/{indexName}.yml
  88. */
  89. export function setConfigIndexName(name: string): void {
  90. // Resolve relative paths to absolute paths and sanitize for use as filename
  91. if (name.includes('/')) {
  92. const { resolve } = require('path');
  93. const { cwd } = require('process');
  94. const absolutePath = resolve(cwd(), name);
  95. // Replace path separators with underscores to create a valid filename
  96. currentIndexName = absolutePath.replace(/\//g, '_').replace(/^_/, '');
  97. } else {
  98. currentIndexName = name;
  99. }
  100. }
  101. function getConfigDir(): string {
  102. // Allow override via QMD_CONFIG_DIR for testing
  103. if (process.env.QMD_CONFIG_DIR) {
  104. return process.env.QMD_CONFIG_DIR;
  105. }
  106. // Respect XDG Base Directory specification (consistent with store.ts)
  107. if (process.env.XDG_CONFIG_HOME) {
  108. return join(process.env.XDG_CONFIG_HOME, "qmd");
  109. }
  110. return join(homedir(), ".config", "qmd");
  111. }
  112. function getConfigFilePath(): string {
  113. return join(getConfigDir(), `${currentIndexName}.yml`);
  114. }
  115. /**
  116. * Ensure config directory exists
  117. */
  118. function ensureConfigDir(): void {
  119. const configDir = getConfigDir();
  120. if (!existsSync(configDir)) {
  121. mkdirSync(configDir, { recursive: true });
  122. }
  123. }
  124. // ============================================================================
  125. // Core functions
  126. // ============================================================================
  127. /**
  128. * Load configuration from the configured source.
  129. * - Inline config: returns the in-memory object directly
  130. * - File-based: reads from YAML file (default ~/.config/qmd/index.yml)
  131. * Returns empty config if file doesn't exist
  132. */
  133. export function loadConfig(): CollectionConfig {
  134. // SDK inline config mode
  135. if (configSource.type === 'inline') {
  136. return configSource.config;
  137. }
  138. // File-based config (SDK custom path or default)
  139. const configPath = configSource.path || getConfigFilePath();
  140. if (!existsSync(configPath)) {
  141. return { collections: {} };
  142. }
  143. try {
  144. const content = readFileSync(configPath, "utf-8");
  145. const config = YAML.parse(content) as CollectionConfig;
  146. // Ensure collections object exists
  147. if (!config.collections) {
  148. config.collections = {};
  149. }
  150. return config;
  151. } catch (error) {
  152. throw new Error(`Failed to parse ${configPath}: ${error}`);
  153. }
  154. }
  155. /**
  156. * Save configuration to the configured source.
  157. * - Inline config: updates the in-memory object (no file I/O)
  158. * - File-based: writes to YAML file (default ~/.config/qmd/index.yml)
  159. */
  160. export function saveConfig(config: CollectionConfig): void {
  161. // SDK inline config mode: update in place, no file I/O
  162. if (configSource.type === 'inline') {
  163. configSource.config = config;
  164. return;
  165. }
  166. const configPath = configSource.path || getConfigFilePath();
  167. const configDir = dirname(configPath);
  168. if (!existsSync(configDir)) {
  169. mkdirSync(configDir, { recursive: true });
  170. }
  171. try {
  172. const yaml = YAML.stringify(config, {
  173. indent: 2,
  174. lineWidth: 0, // Don't wrap lines
  175. });
  176. writeFileSync(configPath, yaml, "utf-8");
  177. } catch (error) {
  178. throw new Error(`Failed to write ${configPath}: ${error}`);
  179. }
  180. }
  181. /**
  182. * Get a specific collection by name
  183. * Returns null if not found
  184. */
  185. export function getCollection(name: string): NamedCollection | null {
  186. const config = loadConfig();
  187. const collection = config.collections[name];
  188. if (!collection) {
  189. return null;
  190. }
  191. return { name, ...collection };
  192. }
  193. /**
  194. * List all collections
  195. */
  196. export function listCollections(): NamedCollection[] {
  197. const config = loadConfig();
  198. return Object.entries(config.collections).map(([name, collection]) => ({
  199. name,
  200. ...collection,
  201. }));
  202. }
  203. /**
  204. * Get collections that are included by default in queries
  205. */
  206. export function getDefaultCollections(): NamedCollection[] {
  207. return listCollections().filter(c => c.includeByDefault !== false);
  208. }
  209. /**
  210. * Get collection names that are included by default
  211. */
  212. export function getDefaultCollectionNames(): string[] {
  213. return getDefaultCollections().map(c => c.name);
  214. }
  215. /**
  216. * Update a collection's settings
  217. */
  218. export function updateCollectionSettings(
  219. name: string,
  220. settings: { update?: string | null; includeByDefault?: boolean }
  221. ): boolean {
  222. const config = loadConfig();
  223. const collection = config.collections[name];
  224. if (!collection) return false;
  225. if (settings.update !== undefined) {
  226. if (settings.update === null) {
  227. delete collection.update;
  228. } else {
  229. collection.update = settings.update;
  230. }
  231. }
  232. if (settings.includeByDefault !== undefined) {
  233. if (settings.includeByDefault === true) {
  234. // true is default, remove the field
  235. delete collection.includeByDefault;
  236. } else {
  237. collection.includeByDefault = settings.includeByDefault;
  238. }
  239. }
  240. saveConfig(config);
  241. return true;
  242. }
  243. /**
  244. * Add or update a collection
  245. */
  246. export function addCollection(
  247. name: string,
  248. path: string,
  249. pattern: string = "**/*.md"
  250. ): void {
  251. const config = loadConfig();
  252. config.collections[name] = {
  253. path,
  254. pattern,
  255. context: config.collections[name]?.context, // Preserve existing context
  256. };
  257. saveConfig(config);
  258. }
  259. /**
  260. * Remove a collection
  261. */
  262. export function removeCollection(name: string): boolean {
  263. const config = loadConfig();
  264. if (!config.collections[name]) {
  265. return false;
  266. }
  267. delete config.collections[name];
  268. saveConfig(config);
  269. return true;
  270. }
  271. /**
  272. * Rename a collection
  273. */
  274. export function renameCollection(oldName: string, newName: string): boolean {
  275. const config = loadConfig();
  276. if (!config.collections[oldName]) {
  277. return false;
  278. }
  279. if (config.collections[newName]) {
  280. throw new Error(`Collection '${newName}' already exists`);
  281. }
  282. config.collections[newName] = config.collections[oldName];
  283. delete config.collections[oldName];
  284. saveConfig(config);
  285. return true;
  286. }
  287. // ============================================================================
  288. // Context management
  289. // ============================================================================
  290. /**
  291. * Get global context
  292. */
  293. export function getGlobalContext(): string | undefined {
  294. const config = loadConfig();
  295. return config.global_context;
  296. }
  297. /**
  298. * Set global context
  299. */
  300. export function setGlobalContext(context: string | undefined): void {
  301. const config = loadConfig();
  302. config.global_context = context;
  303. saveConfig(config);
  304. }
  305. /**
  306. * Get all contexts for a collection
  307. */
  308. export function getContexts(collectionName: string): ContextMap | undefined {
  309. const collection = getCollection(collectionName);
  310. return collection?.context;
  311. }
  312. /**
  313. * Add or update a context for a specific path in a collection
  314. */
  315. export function addContext(
  316. collectionName: string,
  317. pathPrefix: string,
  318. contextText: string
  319. ): boolean {
  320. const config = loadConfig();
  321. const collection = config.collections[collectionName];
  322. if (!collection) {
  323. return false;
  324. }
  325. if (!collection.context) {
  326. collection.context = {};
  327. }
  328. collection.context[pathPrefix] = contextText;
  329. saveConfig(config);
  330. return true;
  331. }
  332. /**
  333. * Remove a context from a collection
  334. */
  335. export function removeContext(
  336. collectionName: string,
  337. pathPrefix: string
  338. ): boolean {
  339. const config = loadConfig();
  340. const collection = config.collections[collectionName];
  341. if (!collection?.context?.[pathPrefix]) {
  342. return false;
  343. }
  344. delete collection.context[pathPrefix];
  345. // Remove empty context object
  346. if (Object.keys(collection.context).length === 0) {
  347. delete collection.context;
  348. }
  349. saveConfig(config);
  350. return true;
  351. }
  352. /**
  353. * List all contexts across all collections
  354. */
  355. export function listAllContexts(): Array<{
  356. collection: string;
  357. path: string;
  358. context: string;
  359. }> {
  360. const config = loadConfig();
  361. const results: Array<{ collection: string; path: string; context: string }> = [];
  362. // Add global context if present
  363. if (config.global_context) {
  364. results.push({
  365. collection: "*",
  366. path: "/",
  367. context: config.global_context,
  368. });
  369. }
  370. // Add collection contexts
  371. for (const [name, collection] of Object.entries(config.collections)) {
  372. if (collection.context) {
  373. for (const [path, context] of Object.entries(collection.context)) {
  374. results.push({
  375. collection: name,
  376. path,
  377. context,
  378. });
  379. }
  380. }
  381. }
  382. return results;
  383. }
  384. /**
  385. * Find best matching context for a given collection and path
  386. * Returns the most specific matching context (longest path prefix match)
  387. */
  388. export function findContextForPath(
  389. collectionName: string,
  390. filePath: string
  391. ): string | undefined {
  392. const config = loadConfig();
  393. const collection = config.collections[collectionName];
  394. if (!collection?.context) {
  395. return config.global_context;
  396. }
  397. // Find all matching prefixes
  398. const matches: Array<{ prefix: string; context: string }> = [];
  399. for (const [prefix, context] of Object.entries(collection.context)) {
  400. // Normalize paths for comparison
  401. const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
  402. const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
  403. if (normalizedPath.startsWith(normalizedPrefix)) {
  404. matches.push({ prefix: normalizedPrefix, context });
  405. }
  406. }
  407. // Return most specific match (longest prefix)
  408. if (matches.length > 0) {
  409. matches.sort((a, b) => b.prefix.length - a.prefix.length);
  410. return matches[0]!.context;
  411. }
  412. // Fallback to global context
  413. return config.global_context;
  414. }
  415. // ============================================================================
  416. // Utility functions
  417. // ============================================================================
  418. /**
  419. * Get the config file path (useful for error messages)
  420. */
  421. export function getConfigPath(): string {
  422. if (configSource.type === 'inline') return '<inline>';
  423. return configSource.path || getConfigFilePath();
  424. }
  425. /**
  426. * Check if config file exists
  427. */
  428. export function configExists(): boolean {
  429. if (configSource.type === 'inline') return true;
  430. const path = configSource.path || getConfigFilePath();
  431. return existsSync(path);
  432. }
  433. /**
  434. * Validate a collection name
  435. * Collection names must be valid and not contain special characters
  436. */
  437. export function isValidCollectionName(name: string): boolean {
  438. // Allow alphanumeric, hyphens, underscores
  439. return /^[a-zA-Z0-9_-]+$/.test(name);
  440. }