Files
tolaria/tools/qmd/src/collections.ts
Test bb20ef17f6 fix: bundle qmd source in tools/qmd/ so CI can compile it
bundle-qmd.sh was trying to install qmd via 'bun install -g qmd' which
installs a different public npm package, not Luca's qmd tool. CI runners
(runner user) don't have the local qmd installation.

Fix:
- Copy qmd source (src/, package.json, tsconfig.json, bun.lock) to tools/qmd/
- Update bundle-qmd.sh to prefer tools/qmd/ as QMD_SRC
- Run 'bun install --frozen-lockfile' in QMD_SRC if node_modules missing
- Update sqlite-vec lookup to find packages from node_modules after bun install
- Compilation uses 'cd $QMD_SRC && bun build --compile src/qmd.ts'
- Add tools/ to eslint globalIgnores (qmd source has its own lint standards)
- Local dev machines still work (tools/qmd/ takes priority over global install)
2026-03-06 08:22:20 +01:00

391 lines
9.3 KiB
TypeScript

/**
* Collections configuration management
*
* This module manages the YAML-based collection configuration at ~/.config/qmd/index.yml.
* Collections define which directories to index and their associated contexts.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import YAML from "yaml";
// ============================================================================
// Types
// ============================================================================
/**
* Context definitions for a collection
* Key is path prefix (e.g., "/", "/2024", "/Board of Directors")
* Value is the context description
*/
export type ContextMap = Record<string, string>;
/**
* A single collection configuration
*/
export interface Collection {
path: string; // Absolute path to index
pattern: string; // Glob pattern (e.g., "**/*.md")
context?: ContextMap; // Optional context definitions
update?: string; // Optional bash command to run during qmd update
}
/**
* The complete configuration file structure
*/
export interface CollectionConfig {
global_context?: string; // Context applied to all collections
collections: Record<string, Collection>; // Collection name -> config
}
/**
* Collection with its name (for return values)
*/
export interface NamedCollection extends Collection {
name: string;
}
// ============================================================================
// Configuration paths
// ============================================================================
// Current index name (default: "index")
let currentIndexName: string = "index";
/**
* Set the current index name for config file lookup
* Config file will be ~/.config/qmd/{indexName}.yml
*/
export function setConfigIndexName(name: string): void {
currentIndexName = name;
}
function getConfigDir(): string {
// Allow override via QMD_CONFIG_DIR for testing
if (process.env.QMD_CONFIG_DIR) {
return process.env.QMD_CONFIG_DIR;
}
return join(homedir(), ".config", "qmd");
}
function getConfigFilePath(): string {
return join(getConfigDir(), `${currentIndexName}.yml`);
}
/**
* Ensure config directory exists
*/
function ensureConfigDir(): void {
const configDir = getConfigDir();
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
}
// ============================================================================
// Core functions
// ============================================================================
/**
* Load configuration from ~/.config/qmd/index.yml
* Returns empty config if file doesn't exist
*/
export function loadConfig(): CollectionConfig {
const configPath = getConfigFilePath();
if (!existsSync(configPath)) {
return { collections: {} };
}
try {
const content = readFileSync(configPath, "utf-8");
const config = YAML.parse(content) as CollectionConfig;
// Ensure collections object exists
if (!config.collections) {
config.collections = {};
}
return config;
} catch (error) {
throw new Error(`Failed to parse ${configPath}: ${error}`);
}
}
/**
* Save configuration to ~/.config/qmd/index.yml
*/
export function saveConfig(config: CollectionConfig): void {
ensureConfigDir();
const configPath = getConfigFilePath();
try {
const yaml = YAML.stringify(config, {
indent: 2,
lineWidth: 0, // Don't wrap lines
});
writeFileSync(configPath, yaml, "utf-8");
} catch (error) {
throw new Error(`Failed to write ${configPath}: ${error}`);
}
}
/**
* Get a specific collection by name
* Returns null if not found
*/
export function getCollection(name: string): NamedCollection | null {
const config = loadConfig();
const collection = config.collections[name];
if (!collection) {
return null;
}
return { name, ...collection };
}
/**
* List all collections
*/
export function listCollections(): NamedCollection[] {
const config = loadConfig();
return Object.entries(config.collections).map(([name, collection]) => ({
name,
...collection,
}));
}
/**
* Add or update a collection
*/
export function addCollection(
name: string,
path: string,
pattern: string = "**/*.md"
): void {
const config = loadConfig();
config.collections[name] = {
path,
pattern,
context: config.collections[name]?.context, // Preserve existing context
};
saveConfig(config);
}
/**
* Remove a collection
*/
export function removeCollection(name: string): boolean {
const config = loadConfig();
if (!config.collections[name]) {
return false;
}
delete config.collections[name];
saveConfig(config);
return true;
}
/**
* Rename a collection
*/
export function renameCollection(oldName: string, newName: string): boolean {
const config = loadConfig();
if (!config.collections[oldName]) {
return false;
}
if (config.collections[newName]) {
throw new Error(`Collection '${newName}' already exists`);
}
config.collections[newName] = config.collections[oldName];
delete config.collections[oldName];
saveConfig(config);
return true;
}
// ============================================================================
// Context management
// ============================================================================
/**
* Get global context
*/
export function getGlobalContext(): string | undefined {
const config = loadConfig();
return config.global_context;
}
/**
* Set global context
*/
export function setGlobalContext(context: string | undefined): void {
const config = loadConfig();
config.global_context = context;
saveConfig(config);
}
/**
* Get all contexts for a collection
*/
export function getContexts(collectionName: string): ContextMap | undefined {
const collection = getCollection(collectionName);
return collection?.context;
}
/**
* Add or update a context for a specific path in a collection
*/
export function addContext(
collectionName: string,
pathPrefix: string,
contextText: string
): boolean {
const config = loadConfig();
const collection = config.collections[collectionName];
if (!collection) {
return false;
}
if (!collection.context) {
collection.context = {};
}
collection.context[pathPrefix] = contextText;
saveConfig(config);
return true;
}
/**
* Remove a context from a collection
*/
export function removeContext(
collectionName: string,
pathPrefix: string
): boolean {
const config = loadConfig();
const collection = config.collections[collectionName];
if (!collection?.context?.[pathPrefix]) {
return false;
}
delete collection.context[pathPrefix];
// Remove empty context object
if (Object.keys(collection.context).length === 0) {
delete collection.context;
}
saveConfig(config);
return true;
}
/**
* List all contexts across all collections
*/
export function listAllContexts(): Array<{
collection: string;
path: string;
context: string;
}> {
const config = loadConfig();
const results: Array<{ collection: string; path: string; context: string }> = [];
// Add global context if present
if (config.global_context) {
results.push({
collection: "*",
path: "/",
context: config.global_context,
});
}
// Add collection contexts
for (const [name, collection] of Object.entries(config.collections)) {
if (collection.context) {
for (const [path, context] of Object.entries(collection.context)) {
results.push({
collection: name,
path,
context,
});
}
}
}
return results;
}
/**
* Find best matching context for a given collection and path
* Returns the most specific matching context (longest path prefix match)
*/
export function findContextForPath(
collectionName: string,
filePath: string
): string | undefined {
const config = loadConfig();
const collection = config.collections[collectionName];
if (!collection?.context) {
return config.global_context;
}
// Find all matching prefixes
const matches: Array<{ prefix: string; context: string }> = [];
for (const [prefix, context] of Object.entries(collection.context)) {
// Normalize paths for comparison
const normalizedPath = filePath.startsWith("/") ? filePath : `/${filePath}`;
const normalizedPrefix = prefix.startsWith("/") ? prefix : `/${prefix}`;
if (normalizedPath.startsWith(normalizedPrefix)) {
matches.push({ prefix: normalizedPrefix, context });
}
}
// Return most specific match (longest prefix)
if (matches.length > 0) {
matches.sort((a, b) => b.prefix.length - a.prefix.length);
return matches[0]!.context;
}
// Fallback to global context
return config.global_context;
}
// ============================================================================
// Utility functions
// ============================================================================
/**
* Get the config file path (useful for error messages)
*/
export function getConfigPath(): string {
return getConfigFilePath();
}
/**
* Check if config file exists
*/
export function configExists(): boolean {
return existsSync(getConfigFilePath());
}
/**
* Validate a collection name
* Collection names must be valid and not contain special characters
*/
export function isValidCollectionName(name: string): boolean {
// Allow alphanumeric, hyphens, underscores
return /^[a-zA-Z0-9_-]+$/.test(name);
}