logancyang_obsidian-copilot/src/projects/projectUtils.ts
2026-05-15 18:26:40 -07:00

481 lines
18 KiB
TypeScript

import { ProjectConfig } from "@/aiParams";
import {
COPILOT_PROJECT_CREATED,
COPILOT_PROJECT_DESCRIPTION,
COPILOT_PROJECT_EXCLUSIONS,
COPILOT_PROJECT_ID,
COPILOT_PROJECT_INCLUSIONS,
COPILOT_PROJECT_LAST_USED,
COPILOT_PROJECT_MAX_TOKENS,
COPILOT_PROJECT_MODEL_KEY,
COPILOT_PROJECT_NAME,
COPILOT_PROJECT_TEMPERATURE,
COPILOT_PROJECT_WEB_URLS,
COPILOT_PROJECT_YOUTUBE_URLS,
EMPTY_PROJECT_CONFIG,
PROJECT_CONFIG_FILE_NAME,
PROJECTS_UNSUPPORTED_FOLDER_NAME,
} from "@/projects/constants";
import {
getProjectFolderNameFromConfigPath,
getProjectsFolder,
splitUrlsStringToArray,
} from "@/projects/projectPaths";
import { ProjectFileRecord, ProjectScanDiagnostics } from "@/projects/type";
import { stripFrontmatter } from "@/utils";
import { logError, logWarn } from "@/logger";
import { parseYaml, TFile, TFolder } from "obsidian";
import {
addPendingFileWrite,
isPendingFileWrite,
removePendingFileWrite,
updateCachedProjectRecords,
} from "@/projects/state";
// Re-export path utilities so existing consumers don't need to change imports
export {
getProjectsFolder,
getProjectsUnsupportedFolder,
getProjectFolderPath,
getProjectConfigFilePath,
isProjectConfigFile,
sanitizeVaultPathSegment,
splitUrlsStringToArray,
readFrontmatterFieldFromFile,
} from "@/projects/projectPaths";
/**
* Write all project frontmatter fields to a file (overwrite mode).
* Shared by both ProjectFileManager and migration to avoid duplication.
*
* @param file - Target TFile
* @param project - ProjectConfig to serialize
* @param folderName - Folder name (used as fallback for id/name)
* @param timestamps - Created and last-used timestamps
*/
export async function writeProjectFrontmatter(
file: TFile,
project: ProjectConfig,
folderName: string,
timestamps: { createdMs: number; lastUsedMs: number }
): Promise<void> {
const webUrls = splitUrlsStringToArray(project.contextSource?.webUrls || "");
const youtubeUrls = splitUrlsStringToArray(project.contextSource?.youtubeUrls || "");
await app.fileManager.processFrontMatter(file, (frontmatter: Record<string, unknown>) => {
// Reason: project.id is the stable logical identity, always set by createProject/migration.
// Do NOT fallback to folderName — with name-based folders, folderName is derived from
// project name, not id, so it cannot serve as an id substitute.
frontmatter[COPILOT_PROJECT_ID] = project.id.trim();
frontmatter[COPILOT_PROJECT_NAME] = (project.name || folderName).trim();
frontmatter[COPILOT_PROJECT_DESCRIPTION] = (project.description || "").trim();
frontmatter[COPILOT_PROJECT_MODEL_KEY] = (project.projectModelKey || "").trim();
if (project.modelConfigs?.temperature != null) {
frontmatter[COPILOT_PROJECT_TEMPERATURE] = project.modelConfigs.temperature;
} else {
delete frontmatter[COPILOT_PROJECT_TEMPERATURE];
}
if (project.modelConfigs?.maxTokens != null) {
frontmatter[COPILOT_PROJECT_MAX_TOKENS] = project.modelConfigs.maxTokens;
} else {
delete frontmatter[COPILOT_PROJECT_MAX_TOKENS];
}
frontmatter[COPILOT_PROJECT_INCLUSIONS] = project.contextSource?.inclusions || "";
frontmatter[COPILOT_PROJECT_EXCLUSIONS] = project.contextSource?.exclusions || "";
frontmatter[COPILOT_PROJECT_WEB_URLS] = webUrls;
frontmatter[COPILOT_PROJECT_YOUTUBE_URLS] = youtubeUrls;
frontmatter[COPILOT_PROJECT_CREATED] = timestamps.createdMs;
frontmatter[COPILOT_PROJECT_LAST_USED] = timestamps.lastUsedMs;
});
}
/**
* Coerce a frontmatter value to a finite number.
* Handles YAML parsing string values gracefully.
*/
function coerceFrontmatterNumber(value: unknown, fallback: number): number {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return fallback;
}
/** Coerce a frontmatter value to string with fallback. */
function coerceFrontmatterString(value: unknown, fallback: string): string {
if (typeof value === "string") return value;
return fallback;
}
/**
* Coerce a frontmatter value to a string array.
* Handles both YAML arrays and single strings (split by newline).
*/
function coerceFrontmatterStringArray(value: unknown): string[] {
if (Array.isArray(value)) {
return value
.filter((v): v is string => typeof v === "string")
.map((v) => v.trim())
.filter(Boolean);
}
if (typeof value === "string") {
return value
.split("\n")
.map((v) => v.trim())
.filter(Boolean);
}
return [];
}
/**
* Strip YAML folding artifacts from encoded strings.
* Long encoded strings may be folded by YAML serializers, inserting newlines/spaces.
*/
function stripYamlFoldingArtifacts(value: string): string {
// Reason: YAML serializers may fold long strings, inserting \n followed by spaces.
// Encoded strings (URL-encoded inclusions/exclusions) must not contain these artifacts.
return value.replace(/\n\s*/g, "").replace(/\r/g, "");
}
/** Convert URL array to newline-separated string (for ProjectConfig runtime format). */
function joinUrlsArrayToString(urls: string[]): string {
return (urls || [])
.map((u) => u.trim())
.filter(Boolean)
.join("\n");
}
/**
* Parse a project.md file into a ProjectFileRecord.
*
* Key constraints:
* - id: frontmatter is authoritative, folder name is fallback
* - Frontmatter parse failure: return null and logWarn (no auto-fix)
* - inclusions/exclusions: kept as encoded strings with YAML folding stripped
* - webUrls/youtubeUrls: stored as YAML arrays, converted to newline strings for runtime
*
* @param file - project.md TFile
* @returns ProjectFileRecord, or null if parse fails
*/
export async function parseProjectConfigFile(file: TFile): Promise<ProjectFileRecord | null> {
// Reason: vault.read() calls internal TFile.cache which doesn't exist on synthetic TFiles
// created by resolveFileByPath() for hidden-folder or not-yet-indexed files.
// Use the cached real TFile for vault API calls; fall back to adapter for synthetic files.
const cachedFile = app.vault.getAbstractFileByPath(file.path);
const isRealVaultFile = cachedFile instanceof TFile;
const rawContent = isRealVaultFile
? await app.vault.read(cachedFile)
: await app.vault.adapter.read(file.path);
const content = stripFrontmatter(rawContent, { trimStart: false });
// Reason: rawContent is authoritative — metadataCache can lag behind writes from
// ensureProjectFrontmatter(), external edits, or sync. Parse frontmatter from the
// just-read file first; only fall back to metadataCache when no frontmatter block exists.
let frontmatter: Record<string, unknown> | undefined;
const fmMatch = rawContent.replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (fmMatch) {
try {
const parsed = parseYaml(fmMatch[1]);
if (parsed && typeof parsed === "object") {
frontmatter = parsed as Record<string, unknown>;
}
} catch {
logWarn(`[Projects] Failed to parse YAML frontmatter from file: ${file.path}`);
return null;
}
}
if (!frontmatter && isRealVaultFile) {
const metadata = app.metadataCache.getFileCache(cachedFile);
frontmatter = metadata?.frontmatter;
}
const folderName = getProjectFolderNameFromConfigPath(file.path);
if (!folderName) {
logWarn(`[Projects] Cannot extract folder name from path, ignoring file: ${file.path}`);
return null;
}
// Reason: frontmatter id is the sole authoritative identity. Do NOT fallback to folderName
// because with name-based folders, folderName is derived from project name, not id.
// Files missing copilot-project-id are treated as corrupted and skipped.
// Reason: YAML scalar typing can parse bare numeric ids (e.g. `copilot-project-id: 123`)
// as numbers. Coerce to string so valid numeric ids are not silently dropped.
const rawId = frontmatter?.[COPILOT_PROJECT_ID];
const idFromFrontmatter =
typeof rawId === "number" && Number.isFinite(rawId)
? String(rawId)
: coerceFrontmatterString(rawId, "");
if (!idFromFrontmatter.trim()) {
logWarn(`[Projects] Missing ${COPILOT_PROJECT_ID} in frontmatter, skipping file: ${file.path}`);
return null;
}
const projectId = idFromFrontmatter.trim();
const nameFromFrontmatter = coerceFrontmatterString(
frontmatter?.[COPILOT_PROJECT_NAME],
""
).trim();
const projectName = nameFromFrontmatter || folderName;
const description = coerceFrontmatterString(
frontmatter?.[COPILOT_PROJECT_DESCRIPTION],
""
).trim();
const projectModelKey = coerceFrontmatterString(
frontmatter?.[COPILOT_PROJECT_MODEL_KEY],
""
).trim();
const temperature = coerceFrontmatterNumber(frontmatter?.[COPILOT_PROJECT_TEMPERATURE], NaN);
const maxTokens = coerceFrontmatterNumber(frontmatter?.[COPILOT_PROJECT_MAX_TOKENS], NaN);
// Encoded strings: strip YAML folding artifacts, keep encoded format
const rawInclusions = coerceFrontmatterString(frontmatter?.[COPILOT_PROJECT_INCLUSIONS], "");
const rawExclusions = coerceFrontmatterString(frontmatter?.[COPILOT_PROJECT_EXCLUSIONS], "");
const inclusions = stripYamlFoldingArtifacts(rawInclusions);
const exclusions = stripYamlFoldingArtifacts(rawExclusions);
const webUrlsArray = coerceFrontmatterStringArray(frontmatter?.[COPILOT_PROJECT_WEB_URLS]);
const youtubeUrlsArray = coerceFrontmatterStringArray(
frontmatter?.[COPILOT_PROJECT_YOUTUBE_URLS]
);
const createdMs = coerceFrontmatterNumber(
frontmatter?.[COPILOT_PROJECT_CREATED],
file.stat?.ctime ?? 0
);
const lastUsedMs = coerceFrontmatterNumber(frontmatter?.[COPILOT_PROJECT_LAST_USED], 0);
const modelConfigs: ProjectFileRecord["project"]["modelConfigs"] = {};
if (Number.isFinite(temperature)) {
modelConfigs.temperature = temperature;
}
if (Number.isFinite(maxTokens) && maxTokens > 0) {
modelConfigs.maxTokens = maxTokens;
}
return {
project: {
...EMPTY_PROJECT_CONFIG,
id: projectId,
name: projectName,
description: description || "",
systemPrompt: content,
projectModelKey,
modelConfigs,
contextSource: {
inclusions: inclusions || "",
exclusions: exclusions || "",
webUrls: joinUrlsArrayToString(webUrlsArray),
youtubeUrls: joinUrlsArrayToString(youtubeUrlsArray),
},
created: Number.isFinite(createdMs) && createdMs > 0 ? createdMs : 0,
UsageTimestamps: Number.isFinite(lastUsedMs) && lastUsedMs > 0 ? lastUsedMs : 0,
},
filePath: file.path,
folderName,
};
}
/**
* Scan all project config files with duplicate id detection.
*
* Performance: only traverses the projectsFolder subtree, not the entire vault.
* Looks for \<projectsFolder\>/\<folderName\>/project.md (one level deep).
*
* Duplicate rules:
* - Build id -> path[] index during scan
* - On duplicate: logWarn, keep first by path alphabetical order (stable)
*
* @returns Records and diagnostics
*/
export async function scanAllProjectConfigFiles(): Promise<{
records: ProjectFileRecord[];
diagnostics: ProjectScanDiagnostics;
}> {
// Reason: §1.8 requires targeted folder traversal, not vault-wide scan.
// Falls back to adapter-based listing for hidden folders not indexed by vault cache.
const projectsFolder = getProjectsFolder();
const rootFolder = app.vault.getAbstractFileByPath(projectsFolder);
const files: TFile[] = [];
if (rootFolder instanceof TFolder) {
for (const child of rootFolder.children) {
if (!(child instanceof TFolder)) continue;
if (child.name === PROJECTS_UNSUPPORTED_FOLDER_NAME) continue;
const configFile = child.children.find(
(f) => f instanceof TFile && f.name === PROJECT_CONFIG_FILE_NAME
);
if (configFile instanceof TFile) {
files.push(configFile);
}
}
} else if (await app.vault.adapter.exists(projectsFolder)) {
// Reason: hidden folders (e.g. ".copilot/projects") are not indexed by vault cache.
// Use adapter.list() to discover project sub-folders and resolve config files.
const { resolveFileByPath } = await import("@/utils/vaultAdapterUtils");
const listing = await app.vault.adapter.list(projectsFolder);
for (const subFolderPath of listing.folders) {
const folderName = subFolderPath.split("/").pop() ?? "";
if (folderName === PROJECTS_UNSUPPORTED_FOLDER_NAME) continue;
const configPath = `${subFolderPath}/${PROJECT_CONFIG_FILE_NAME}`;
if (await app.vault.adapter.exists(configPath)) {
const resolved = await resolveFileByPath(app, configPath);
if (resolved) files.push(resolved);
}
}
}
files.sort((a, b) => a.path.localeCompare(b.path));
const duplicateIdIndex: Record<string, string[]> = {};
const ignoredFiles: string[] = [];
const records: ProjectFileRecord[] = [];
for (const file of files) {
let record: ProjectFileRecord | null;
try {
record = await parseProjectConfigFile(file);
} catch (error) {
logError(`[Projects] Failed to parse project file, skipping: ${file.path}`, error);
ignoredFiles.push(file.path);
continue;
}
if (!record) {
ignoredFiles.push(file.path);
continue;
}
const id = record.project.id;
if (!duplicateIdIndex[id]) {
duplicateIdIndex[id] = [];
}
duplicateIdIndex[id].push(file.path);
if (duplicateIdIndex[id].length > 1) {
logWarn(
`[Projects] Duplicate project id="${id}": ` +
`${duplicateIdIndex[id].join(", ")}; keeping first: ${duplicateIdIndex[id][0]}`
);
continue;
}
records.push(record);
}
return { records, diagnostics: { duplicateIdIndex, ignoredFiles } };
}
/**
* Load all projects from vault and update the cache.
*
* NOTE: This does NOT merge legacy settings.projectList entries.
* Migration unconditionally clears projectList after backup (matching the
* custom command and system prompt migration patterns). Failed projects
* are recovered from the unsupported/ folder, not from settings.
*
* @returns Array of ProjectFileRecord
*/
export async function loadAllProjects(): Promise<ProjectFileRecord[]> {
const { records } = await scanAllProjectConfigFiles();
updateCachedProjectRecords(records);
return records;
}
/**
* Fetch all projects from vault without updating the cache.
* @returns Array of ProjectFileRecord
*/
export async function fetchAllProjects(): Promise<ProjectFileRecord[]> {
const { records } = await scanAllProjectConfigFiles();
return records;
}
/**
* Ensure a project.md has required frontmatter fields (idempotent, only fills missing).
*
* @param file - project.md TFile
* @param record - Parsed record providing default values
*/
export async function ensureProjectFrontmatter(
file: TFile,
record: ProjectFileRecord
): Promise<void> {
const alreadyPending = isPendingFileWrite(file.path);
const now = Date.now();
const createdMs =
Number.isFinite(record.project.created) && record.project.created > 0
? record.project.created
: now;
const lastUsedMs =
Number.isFinite(record.project.UsageTimestamps) && record.project.UsageTimestamps > 0
? record.project.UsageTimestamps
: 0;
const webUrls = splitUrlsStringToArray(record.project.contextSource?.webUrls || "");
const youtubeUrls = splitUrlsStringToArray(record.project.contextSource?.youtubeUrls || "");
try {
if (!alreadyPending) addPendingFileWrite(file.path);
await app.fileManager.processFrontMatter(file, (frontmatter: Record<string, unknown>) => {
// Reason: do NOT fallback to record.folderName for id — with name-based folders,
// folderName is derived from project name, not id.
if (frontmatter[COPILOT_PROJECT_ID] == null && record.project.id) {
frontmatter[COPILOT_PROJECT_ID] = record.project.id;
}
if (frontmatter[COPILOT_PROJECT_NAME] == null) {
frontmatter[COPILOT_PROJECT_NAME] = record.project.name || record.folderName;
}
if (frontmatter[COPILOT_PROJECT_DESCRIPTION] == null && record.project.description) {
frontmatter[COPILOT_PROJECT_DESCRIPTION] = record.project.description;
}
if (frontmatter[COPILOT_PROJECT_MODEL_KEY] == null && record.project.projectModelKey) {
frontmatter[COPILOT_PROJECT_MODEL_KEY] = record.project.projectModelKey;
}
if (
frontmatter[COPILOT_PROJECT_TEMPERATURE] == null &&
record.project.modelConfigs?.temperature != null
) {
frontmatter[COPILOT_PROJECT_TEMPERATURE] = record.project.modelConfigs.temperature;
}
if (
frontmatter[COPILOT_PROJECT_MAX_TOKENS] == null &&
record.project.modelConfigs?.maxTokens != null
) {
frontmatter[COPILOT_PROJECT_MAX_TOKENS] = record.project.modelConfigs.maxTokens;
}
if (
frontmatter[COPILOT_PROJECT_INCLUSIONS] == null &&
record.project.contextSource?.inclusions
) {
frontmatter[COPILOT_PROJECT_INCLUSIONS] = record.project.contextSource.inclusions;
}
if (
frontmatter[COPILOT_PROJECT_EXCLUSIONS] == null &&
record.project.contextSource?.exclusions
) {
frontmatter[COPILOT_PROJECT_EXCLUSIONS] = record.project.contextSource.exclusions;
}
if (frontmatter[COPILOT_PROJECT_WEB_URLS] == null && webUrls.length > 0) {
frontmatter[COPILOT_PROJECT_WEB_URLS] = webUrls;
}
if (frontmatter[COPILOT_PROJECT_YOUTUBE_URLS] == null && youtubeUrls.length > 0) {
frontmatter[COPILOT_PROJECT_YOUTUBE_URLS] = youtubeUrls;
}
if (frontmatter[COPILOT_PROJECT_CREATED] == null) {
frontmatter[COPILOT_PROJECT_CREATED] = createdMs;
}
if (frontmatter[COPILOT_PROJECT_LAST_USED] == null) {
frontmatter[COPILOT_PROJECT_LAST_USED] = lastUsedMs;
}
});
} finally {
if (!alreadyPending) removePendingFileWrite(file.path);
}
}