collections.ts 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390
  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 } 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. context?: ContextMap; // Optional context definitions
  27. update?: string; // Optional bash command to run during qmd update
  28. }
  29. /**
  30. * The complete configuration file structure
  31. */
  32. export interface CollectionConfig {
  33. global_context?: string; // Context applied to all collections
  34. collections: Record<string, Collection>; // Collection name -> config
  35. }
  36. /**
  37. * Collection with its name (for return values)
  38. */
  39. export interface NamedCollection extends Collection {
  40. name: string;
  41. }
  42. // ============================================================================
  43. // Configuration paths
  44. // ============================================================================
  45. // Current index name (default: "index")
  46. let currentIndexName: string = "index";
  47. /**
  48. * Set the current index name for config file lookup
  49. * Config file will be ~/.config/qmd/{indexName}.yml
  50. */
  51. export function setConfigIndexName(name: string): void {
  52. currentIndexName = name;
  53. }
  54. function getConfigDir(): string {
  55. // Allow override via QMD_CONFIG_DIR for testing
  56. if (process.env.QMD_CONFIG_DIR) {
  57. return process.env.QMD_CONFIG_DIR;
  58. }
  59. return join(homedir(), ".config", "qmd");
  60. }
  61. function getConfigFilePath(): string {
  62. return join(getConfigDir(), `${currentIndexName}.yml`);
  63. }
  64. /**
  65. * Ensure config directory exists
  66. */
  67. function ensureConfigDir(): void {
  68. const configDir = getConfigDir();
  69. if (!existsSync(configDir)) {
  70. mkdirSync(configDir, { recursive: true });
  71. }
  72. }
  73. // ============================================================================
  74. // Core functions
  75. // ============================================================================
  76. /**
  77. * Load configuration from ~/.config/qmd/index.yml
  78. * Returns empty config if file doesn't exist
  79. */
  80. export function loadConfig(): CollectionConfig {
  81. const configPath = getConfigFilePath();
  82. if (!existsSync(configPath)) {
  83. return { collections: {} };
  84. }
  85. try {
  86. const content = readFileSync(configPath, "utf-8");
  87. const config = YAML.parse(content) as CollectionConfig;
  88. // Ensure collections object exists
  89. if (!config.collections) {
  90. config.collections = {};
  91. }
  92. return config;
  93. } catch (error) {
  94. throw new Error(`Failed to parse ${configPath}: ${error}`);
  95. }
  96. }
  97. /**
  98. * Save configuration to ~/.config/qmd/index.yml
  99. */
  100. export function saveConfig(config: CollectionConfig): void {
  101. ensureConfigDir();
  102. const configPath = getConfigFilePath();
  103. try {
  104. const yaml = YAML.stringify(config, {
  105. indent: 2,
  106. lineWidth: 0, // Don't wrap lines
  107. });
  108. writeFileSync(configPath, yaml, "utf-8");
  109. } catch (error) {
  110. throw new Error(`Failed to write ${configPath}: ${error}`);
  111. }
  112. }
  113. /**
  114. * Get a specific collection by name
  115. * Returns null if not found
  116. */
  117. export function getCollection(name: string): NamedCollection | null {
  118. const config = loadConfig();
  119. const collection = config.collections[name];
  120. if (!collection) {
  121. return null;
  122. }
  123. return { name, ...collection };
  124. }
  125. /**
  126. * List all collections
  127. */
  128. export function listCollections(): NamedCollection[] {
  129. const config = loadConfig();
  130. return Object.entries(config.collections).map(([name, collection]) => ({
  131. name,
  132. ...collection,
  133. }));
  134. }
  135. /**
  136. * Add or update a collection
  137. */
  138. export function addCollection(
  139. name: string,
  140. path: string,
  141. pattern: string = "**/*.md"
  142. ): void {
  143. const config = loadConfig();
  144. config.collections[name] = {
  145. path,
  146. pattern,
  147. context: config.collections[name]?.context, // Preserve existing context
  148. };
  149. saveConfig(config);
  150. }
  151. /**
  152. * Remove a collection
  153. */
  154. export function removeCollection(name: string): boolean {
  155. const config = loadConfig();
  156. if (!config.collections[name]) {
  157. return false;
  158. }
  159. delete config.collections[name];
  160. saveConfig(config);
  161. return true;
  162. }
  163. /**
  164. * Rename a collection
  165. */
  166. export function renameCollection(oldName: string, newName: string): boolean {
  167. const config = loadConfig();
  168. if (!config.collections[oldName]) {
  169. return false;
  170. }
  171. if (config.collections[newName]) {
  172. throw new Error(`Collection '${newName}' already exists`);
  173. }
  174. config.collections[newName] = config.collections[oldName];
  175. delete config.collections[oldName];
  176. saveConfig(config);
  177. return true;
  178. }
  179. // ============================================================================
  180. // Context management
  181. // ============================================================================
  182. /**
  183. * Get global context
  184. */
  185. export function getGlobalContext(): string | undefined {
  186. const config = loadConfig();
  187. return config.global_context;
  188. }
  189. /**
  190. * Set global context
  191. */
  192. export function setGlobalContext(context: string | undefined): void {
  193. const config = loadConfig();
  194. config.global_context = context;
  195. saveConfig(config);
  196. }
  197. /**
  198. * Get all contexts for a collection
  199. */
  200. export function getContexts(collectionName: string): ContextMap | undefined {
  201. const collection = getCollection(collectionName);
  202. return collection?.context;
  203. }
  204. /**
  205. * Add or update a context for a specific path in a collection
  206. */
  207. export function addContext(
  208. collectionName: string,
  209. pathPrefix: string,
  210. contextText: string
  211. ): boolean {
  212. const config = loadConfig();
  213. const collection = config.collections[collectionName];
  214. if (!collection) {
  215. return false;
  216. }
  217. if (!collection.context) {
  218. collection.context = {};
  219. }
  220. collection.context[pathPrefix] = contextText;
  221. saveConfig(config);
  222. return true;
  223. }
  224. /**
  225. * Remove a context from a collection
  226. */
  227. export function removeContext(
  228. collectionName: string,
  229. pathPrefix: string
  230. ): boolean {
  231. const config = loadConfig();
  232. const collection = config.collections[collectionName];
  233. if (!collection?.context?.[pathPrefix]) {
  234. return false;
  235. }
  236. delete collection.context[pathPrefix];
  237. // Remove empty context object
  238. if (Object.keys(collection.context).length === 0) {
  239. delete collection.context;
  240. }
  241. saveConfig(config);
  242. return true;
  243. }
  244. /**
  245. * List all contexts across all collections
  246. */
  247. export function listAllContexts(): Array<{
  248. collection: string;
  249. path: string;
  250. context: string;
  251. }> {
  252. const config = loadConfig();
  253. const results: Array<{ collection: string; path: string; context: string }> = [];
  254. // Add global context if present
  255. if (config.global_context) {
  256. results.push({
  257. collection: "*",
  258. path: "/",
  259. context: config.global_context,
  260. });
  261. }
  262. // Add collection contexts
  263. for (const [name, collection] of Object.entries(config.collections)) {
  264. if (collection.context) {
  265. for (const [path, context] of Object.entries(collection.context)) {
  266. results.push({
  267. collection: name,
  268. path,
  269. context,
  270. });
  271. }
  272. }
  273. }
  274. return results;
  275. }
  276. /**
  277. * Find best matching context for a given collection and path
  278. * Returns the most specific matching context (longest path prefix match)
  279. */
  280. export function findContextForPath(
  281. collectionName: string,
  282. filePath: string
  283. ): string | undefined {
  284. const config = loadConfig();
  285. const collection = config.collections[collectionName];
  286. if (!collection?.context) {
  287. return config.global_context;
  288. }
  289. // Find all matching prefixes
  290. const matches: Array<{ prefix: string; context: string }> = [];
  291. for (const [prefix, context] of Object.entries(collection.context)) {
  292. // Normalize paths for comparison
  293. const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
  294. const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
  295. if (normalizedPath.startsWith(normalizedPrefix)) {
  296. matches.push({ prefix: normalizedPrefix, context });
  297. }
  298. }
  299. // Return most specific match (longest prefix)
  300. if (matches.length > 0) {
  301. matches.sort((a, b) => b.prefix.length - a.prefix.length);
  302. return matches[0]!.context;
  303. }
  304. // Fallback to global context
  305. return config.global_context;
  306. }
  307. // ============================================================================
  308. // Utility functions
  309. // ============================================================================
  310. /**
  311. * Get the config file path (useful for error messages)
  312. */
  313. export function getConfigPath(): string {
  314. return getConfigFilePath();
  315. }
  316. /**
  317. * Check if config file exists
  318. */
  319. export function configExists(): boolean {
  320. return existsSync(getConfigFilePath());
  321. }
  322. /**
  323. * Validate a collection name
  324. * Collection names must be valid and not contain special characters
  325. */
  326. export function isValidCollectionName(name: string): boolean {
  327. // Allow alphanumeric, hyphens, underscores
  328. return /^[a-zA-Z0-9_-]+$/.test(name);
  329. }