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
This commit is contained in:
Nexus Dev 2026-04-02 00:10:26 +00:00
parent e9f5897eec
commit 2cafff8054
4 changed files with 179 additions and 0 deletions

View file

@ -592,6 +592,7 @@ export type {
ChatFileReference,
ChatFileUploadResponse,
ChatFileListResponse,
ChatPlaceholderEntry,
} from "./types/chat.js";
export { API_PREFIX, API } from "./api.js";

View file

@ -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;
}

View file

@ -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);
},
};
}

View file

@ -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<void> {
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<void> {
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<void> {
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);
},
};
}