collections.js 12 KB

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