From 2cafff80541958d192ae3cfeb127ffaff8b88751 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Thu, 2 Apr 2026 00:10:26 +0000 Subject: [PATCH] feat(25-07): create placeholderService and add markAsPlaceholder method - Create server/src/services/placeholder-service.ts with addEntry, replaceEntry, listEntries - Generates PLACEHOLDERS.md with Active Placeholders and Replaced markdown tables - Add ChatPlaceholderEntry interface to packages/shared/src/types/chat.ts - Export ChatPlaceholderEntry from packages/shared/src/index.ts - Add markAsPlaceholder method to chatFileService in chat-files.ts --- packages/shared/src/index.ts | 1 + packages/shared/src/types/chat.ts | 8 + server/src/services/chat-files.ts | 9 ++ server/src/services/placeholder-service.ts | 161 +++++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 server/src/services/placeholder-service.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 007d6a37..81ef6275 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -592,6 +592,7 @@ export type { ChatFileReference, ChatFileUploadResponse, ChatFileListResponse, + ChatPlaceholderEntry, } from "./types/chat.js"; export { API_PREFIX, API } from "./api.js"; diff --git a/packages/shared/src/types/chat.ts b/packages/shared/src/types/chat.ts index dd1acd5d..622b48c5 100644 --- a/packages/shared/src/types/chat.ts +++ b/packages/shared/src/types/chat.ts @@ -117,3 +117,11 @@ export interface ChatBookmarkListResponse { export interface ChatBookmarkToggleResponse { bookmarked: boolean; } + +export interface ChatPlaceholderEntry { + fileId: string; + filename: string; + description: string; + createdAt: string; + replacedByFileId?: string; +} diff --git a/server/src/services/chat-files.ts b/server/src/services/chat-files.ts index 1f8f2abc..53d3b4bf 100644 --- a/server/src/services/chat-files.ts +++ b/server/src/services/chat-files.ts @@ -118,5 +118,14 @@ export function chatFileService(db: Db) { .returning() .then((rows) => rows[0] ?? null); }, + + markAsPlaceholder(fileId: string) { + return db + .update(chatFiles) + .set({ category: "placeholder", updatedAt: new Date() }) + .where(eq(chatFiles.id, fileId)) + .returning() + .then((rows) => rows[0] ?? null); + }, }; } diff --git a/server/src/services/placeholder-service.ts b/server/src/services/placeholder-service.ts new file mode 100644 index 00000000..619dd692 --- /dev/null +++ b/server/src/services/placeholder-service.ts @@ -0,0 +1,161 @@ +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import path from "node:path"; + +export interface PlaceholderEntry { + fileId: string; + filename: string; + description: string; + replacedByFileId?: string; +} + +const MANIFEST_FILENAME = "PLACEHOLDERS.md"; + +function serialize(active: PlaceholderEntry[], replaced: PlaceholderEntry[]): string { + const activeRows = + active.length === 0 + ? "_None_" + : active.map((e) => `| ${e.filename} | ${e.description} | ${e.fileId} |`).join("\n"); + + const replacedRows = + replaced.length === 0 + ? "_None_" + : replaced + .map((e) => `| ${e.filename} | ${e.description} | ${e.replacedByFileId ?? ""} |`) + .join("\n"); + + return `# Placeholder Assets + +Auto-maintained by Nexus. Do not edit manually. + +## Active Placeholders + +| File | Description | File ID | +|------|-------------|---------| +${activeRows} + +## Replaced + +| Original | Description | Replaced By | +|----------|-------------|-------------| +${replacedRows} +`; +} + +function parse(content: string): { active: PlaceholderEntry[]; replaced: PlaceholderEntry[] } { + const active: PlaceholderEntry[] = []; + const replaced: PlaceholderEntry[] = []; + + let section: "active" | "replaced" | null = null; + const rowRe = /^\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/; + + for (const line of content.split("\n")) { + if (line.includes("## Active Placeholders")) { + section = "active"; + continue; + } + if (line.includes("## Replaced")) { + section = "replaced"; + continue; + } + + if (!section) continue; + + const match = rowRe.exec(line); + if (!match) continue; + + const [, col1, col2, col3] = match; + // Skip header row and separator row + if ( + col1 === "File" || + col1 === "Original" || + col1 === "---" || + col1?.startsWith("-") + ) { + continue; + } + // Skip "_None_" placeholder rows + if (col1?.trim() === "_None_") continue; + + if (section === "active") { + active.push({ + filename: col1 ?? "", + description: col2 ?? "", + fileId: col3 ?? "", + }); + } else { + replaced.push({ + filename: col1 ?? "", + description: col2 ?? "", + replacedByFileId: col3 ?? undefined, + fileId: col3 ?? "", + }); + } + } + + return { active, replaced }; +} + +async function readManifest(projectDir: string): Promise<{ active: PlaceholderEntry[]; replaced: PlaceholderEntry[] }> { + const manifestPath = path.join(projectDir, MANIFEST_FILENAME); + if (!existsSync(manifestPath)) { + return { active: [], replaced: [] }; + } + const content = await readFile(manifestPath, "utf-8"); + return parse(content); +} + +async function writeManifest( + projectDir: string, + active: PlaceholderEntry[], + replaced: PlaceholderEntry[], +): Promise { + await mkdir(projectDir, { recursive: true }); + const manifestPath = path.join(projectDir, MANIFEST_FILENAME); + await writeFile(manifestPath, serialize(active, replaced), "utf-8"); +} + +export function placeholderService() { + return { + async addEntry( + projectDir: string, + entry: { fileId: string; filename: string; description: string }, + ): Promise { + const { active, replaced } = await readManifest(projectDir); + // Avoid duplicates + if (!active.some((e) => e.fileId === entry.fileId)) { + active.push({ + fileId: entry.fileId, + filename: entry.filename, + description: entry.description, + }); + } + await writeManifest(projectDir, active, replaced); + }, + + async replaceEntry( + projectDir: string, + oldFileId: string, + newFileId: string, + ): Promise { + const { active, replaced } = await readManifest(projectDir); + const idx = active.findIndex((e) => e.fileId === oldFileId); + if (idx === -1) return; + + const [entry] = active.splice(idx, 1); + replaced.push({ + fileId: entry!.fileId, + filename: entry!.filename, + description: entry!.description, + replacedByFileId: newFileId, + }); + await writeManifest(projectDir, active, replaced); + }, + + async listEntries( + projectDir: string, + ): Promise<{ active: PlaceholderEntry[]; replaced: PlaceholderEntry[] }> { + return readManifest(projectDir); + }, + }; +}