Forked from acmar/export-to-word-plugin
Forked from acmar/export-to-word-plugin
src / toolsProvider.ts
import { join, isAbsolute, resolve, normalize } from "path";
import { readFile, writeFile, copyFile } from "fs/promises";
import AdmZip from "adm-zip";
import markdownIt from "markdown-it";
import multimdTable from "markdown-it-multimd-table";
import { z } from "zod";
import {
tool,
text,
type Tool,
type ToolsProviderController
} from "@lmstudio/sdk";
const chatMessageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
timestamp: z.number().optional(),
});
/**
* Required function for protecting Word's XML structure.
* Without it, the code crashes with the error "escapeXml is not defined".
*/
function escapeXml(unsafe: string): string {
if (!unsafe) return "";
return unsafe.replace(/[<>&'"]/g, (c) => {
switch (c) {
case "<": return "<";
case ">": return ">";
case "&": return "&";
case "'": return "'";
case '"': return """;
default: return c;
}
});
}
//-------------------------------------------
function parseMarkdownToDocxXml(tokens: markdownIt.Token[]): string[] {
const resultXml: string[] = [];
// Variables for building regular text strings
let currentRunsXml = "";
let isBold = false;
// === VARIABLES FROM BACKUP FOR TABLE ASSEMBLY ===
let isInsideTable = false;
let tableRowsXml: string[] = [];
let currentRowCellsXml: string[] = [];
let currentCellText = "";
// Helper function to flush accumulated plain text into a paragraph
const flushParagraph = () => {
if (currentRunsXml) {
resultXml.push(`<w:p><w:pPr><w:snapToGrid w:val="0"/></w:pPr>${currentRunsXml}</w:p>`);
currentRunsXml = "";
}
};
for (const token of tokens) {
// ==========================================
// [: TABLES — OPENING]
// ==========================================
if (token.type === "table_open") {
flushParagraph(); // Save text that was before the table
isInsideTable = true;
tableRowsXml = [];
continue;
}
// ==========================================
// [: TABLES — CLOSING]
// ==========================================
if (token.type === "table_close") {
isInsideTable = false;
// Form a ready-made table with visible thin borders
const tableXml = `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:left w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:right w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="E0E0E0"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="E0E0E0"/></w:tblBorders></w:tblPr>${tableRowsXml.join("")}</w:tbl>`;
resultXml.push(tableXml);
continue;
}
// ==========================================
// [: TABLES — PROCESSING INSIDE]
// ==========================================
if (isInsideTable) {
if (token.type === "tr_close") {
// Row completed, pack cells into <w:tr> tag
tableRowsXml.push(`<w:tr>${currentRowCellsXml.join("")}</w:tr>`);
currentRowCellsXml = [];
}
else if (token.type === "th_close" || token.type === "td_close") {
// Cell completed, create <w:tc>. Text inside must be wrapped in <w:p>
currentRowCellsXml.push(`<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr><w:p><w:r><w:t>${escapeXml(currentCellText)}</w:t></w:r></w:p></w:tc>`);
currentCellText = "";
}
else if (token.type === "inline") {
// Collect text that is inside the table cell
currentCellText += token.content ?? "";
}
continue; // Skip remaining plain text processing logic while we're inside a table
}
// ==========================================
// [: HEADERS AND PLAIN TEXT]
// ==========================================
if (token.type === "heading_open") {
flushParagraph();
const level = token.tag ? token.tag.replace("h", "") : "1";
const nextToken = tokens[tokens.indexOf(token) + 1];
const textContent = nextToken && nextToken.type === "inline" ? nextToken.content : "";
resultXml.push(`<w:p><w:pPr><w:pStyle w:val="Heading${level}"/></w:pPr><w:r><w:t>${escapeXml(textContent)}</w:t></w:r></w:p>`);
continue;
}
if (token.type === "heading_close" || (token.type === "inline" && tokens[tokens.indexOf(token) - 1]?.type === "heading_open")) {
continue;
}
if (token.type === "inline") {
if (token.children) {
for (const child of token.children) {
if (child.type === "strong_open" || child.type === "bold_open") {
isBold = true;
continue;
}
if (child.type === "strong_close" || child.type === "bold_close") {
isBold = false;
continue;
}
if (child.type === "text") {
currentRunsXml += `<w:r><w:rPr>${isBold ? "<w:b/>" : ""}</w:rPr><w:t xml:space="preserve">${escapeXml(child.content)}</w:t></w:r>`;
}
}
} else {
currentRunsXml += `<w:r><w:t xml:space="preserve">${escapeXml(token.content)}</w:t></w:r>`;
}
flushParagraph();
}
}
flushParagraph();
return resultXml;
}
// ---------------------------------------------------------------------------
// Tool: export_to_docx
// ---------------------------------------------------------------------------
export async function toolsProvider(ctl: ToolsProviderController) {
const tools: Tool[] = [];
// Tool#1:
const exportWordTool = tool({
name: "export_to_docx",
description: "Parses the current chat history (messages, roles, tables) and exports the current conversation into a new structured Word (.docx) file with proper table and header formatting using a document template.",
parameters: {
messages: z.array(chatMessageSchema),
filename: z.string().optional(),
},
implementation: async ({ messages, filename }: { messages: z.infer<typeof chatMessageSchema>[], filename?: string }) => {
const workingDirectory = ctl.getWorkingDirectory();
const now = new Date();
// Inside export_to_docx:
const safeTimestamp = now.toISOString().replace(/[:.]/g, "-");
const baseName = filename ?? `chat-export-${safeTimestamp}`;
const finalName = baseName.toLowerCase().endsWith(".docx") ? baseName : `${baseName}.docx`;
const templatePath = normalize(resolve(join(__dirname, "..", "empty_template.json")));
const outputPath = normalize(resolve(join(workingDirectory, finalName)));
const bodyElements: string[] = [];
// Document header
bodyElements.push(`
<w:p>
<w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
<w:r><w:t>EXPORT CHAT -- LM STUDIO</w:t></w:r>
</w:p>`);
bodyElements.push(`<w:p><w:r><w:t>Date: ${escapeXml(now.toLocaleString())}</w:t></w:r></w:p>`);
bodyElements.push(`<w:p><w:r><w:t>Messages count: ${messages.length || 0}</w:t></w:r></w:p>`);
bodyElements.push(`<w:p/>`);
// Filling messages
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!msg || !msg.content) continue;
const role = msg.role === "user" ? "USER" : msg.role === "assistant" ? "ASSISTANT" : "SYSTEM";
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : "--:--";
bodyElements.push(`
<w:p>
<w:r><w:rPr><w:b/></w:rPr><w:t>[${i + 1}] ${role} (${time})</w:t></w:r>
</w:p>`);
// (Включаем поддержку GFM таблиц):
const md = markdownIt().use(multimdTable, {
multiline: true,
rowspan: true,
headerless: true
});
const tokens = md.parse(msg.content, {});
const parsedParagraphs = parseMarkdownToDocxXml(tokens);
bodyElements.push(...parsedParagraphs);
bodyElements.push(`<w:p/>`);
}
try {
// 1. Loading your JSON template from the project root
const templatePath = join(__dirname, "..", "empty_template.json");
const zip = new AdmZip(templatePath);
// 2. Reading the ORIGINAL XML template with all namespaces
const originalDocXml = zip.readAsText("word/document.xml", "utf-8");
// 3. Automatic content injection
// Look for the <w:body> tag and insert our generated text right after it
const updatedDocXml = originalDocXml.replace(
"<w:body>",
`<w:body>${bodyElements.join("")}`
);
// 4. Rewriting the modified file into the ZIP archive
zip.updateFile("word/document.xml", Buffer.from(updatedDocXml, "utf-8"));
// 5. Saving the ready-made document to disk
const buffer = zip.toBuffer();
await writeFile(outputPath, buffer);
return { success: true, path: outputPath };
} catch (error: any) {
return { success: false, error: `Failed to export. Template error: ${error.message}` };
}
},
});
tools.push(exportWordTool);
// Tool#2: Appending to an existing file...
// Make sure path-related functions are imported at the top of the file: import { join, isAbsolute } from "path";
const appendParagraphTool = tool({
name: "append_paragraph",
description: "Appends new paragraphs, headers, or formatted tables from Markdown to an existing Word (.docx) document. Accepts both relative filenames, full absolute paths, a plain filename or a full absolute path.",
parameters: {
filename: z.string(), // May be "file.docx" or "C:\\folder\\file.docx"
text: z.string(),
},
implementation: async ({ filename, text }: { filename: string; text: string }) => {
const workingDirectory = ctl.getWorkingDirectory();
// ==========================================
// [: MISSING EXTENSION PROTECTION]
// ==========================================
// Check the input string (it may be a name and/or a full path).
// If it doesn't end in .docx, carefully append it.
const safeFilename = filename.toLowerCase().endsWith(".docx") ? filename : `${filename}.docx`;
// ==========================================
// [: CROSS-PLATFORM PATH]
// ==========================================
// 1. Use the already verified safeFilename.
// If absolute — use as-is; if relative — join with working directory.
const baseTarget = isAbsolute(safeFilename) ? safeFilename : join(workingDirectory, safeFilename);
// 2. Normalize path for the target operating system (Windows / Linux / macOS)
const filePath = normalize(resolve(baseTarget));
try {
// Now AdmZip is guaranteed to receive the correct file path on disk
const zip = new AdmZip(filePath);
// Read current XML content
const originalDocXml = zip.readAsText("word/document.xml", "utf-8");
// Parse new incoming data from Markdown into OpenXML strings
const md = markdownIt().use(multimdTable, { headerless: true });
const tokens = md.parse(text, {});
const newElementsXml = parseMarkdownToDocxXml(tokens).join("");
let updatedDocXml = "";
// ==========================================
// [: XML CONTENT INJECTION]
// ==========================================
if (originalDocXml.includes("<w:sectPr")) {
updatedDocXml = originalDocXml.replace("<w:sectPr", `${newElementsXml}<w:sectPr`);
} else if (originalDocXml.includes("</w:body>")) {
updatedDocXml = originalDocXml.replace("</w:body>", `${newElementsXml}</w:body>`);
} else {
throw new Error("Invalid document structure: <w:body> or <w:sectPr> not found.");
}
// Rewrite and save the archive
zip.updateFile("word/document.xml", Buffer.from(updatedDocXml, "utf-8"));
const buffer = zip.toBuffer();
await writeFile(filePath, buffer);
return { success: true, message: `Successfully appended content to ${filename}` };
} catch (error: any) {
return {
success: false,
error: `Failed to append paragraph to ${filename}. Error: ${error.message}`
};
}
},
});
tools.push(appendParagraphTool);
// Tool#3: Create an empty document — returns the path to a brand new .docx file
const createEmptyDocTool = tool({
name: "create_empty_docx",
description: "Creates a fresh blank Word document (.docx) file based on a valid system template. Use this as a starting point for building documents programmatically. Returns the absolute file path.",
parameters: {
filename: z.string().optional(),
},
implementation: async ({ filename }: { filename?: string }) => {
const workingDirectory = ctl.getWorkingDirectory();
// ==========================================
// [: NAME FORMATION WITHOUT DUPLICATION]
// ==========================================
const now = new Date();
const safeTimestamp = now.toISOString().replace(/[:.]/g, "-");
// Базовое имя: берем то, что дал ИИ, или генерируем дефолтное
const baseName = filename ?? `empty-${safeTimestamp}`;
// ПРОВЕРКА: Если имя уже заканчивается на .docx, оставляем как есть.
// Если нет — аккуратно дописываем расширение.
const finalName = baseName.toLowerCase().endsWith(".docx") ? baseName : `${baseName}.docx`;
// ==========================================
// [: CROSS-PLATFORM PATHS]
// ==========================================
// Now finalName is guaranteed to exist and be correct
const templatePath = normalize(resolve(join(__dirname, "..", "empty_template.json")));
const outputPath = normalize(resolve(join(workingDirectory, finalName)));
try {
// ---------- Simple and reliable file copy ----------
// Copy the ready-made empty template file to the target folder
await copyFile(templatePath, outputPath);
return { success: true, path: outputPath };
} catch (error: any) {
// If you forgot to place the template in the src folder, output a clear error
return {
success: false,
error: `Template file not found at ${templatePath}. Please ensure template.docx is present in the plugin directory.`
};
}
},
});
tools.push(createEmptyDocTool);
return tools;
}
//end.src / toolsProvider.ts
import { join, isAbsolute, resolve, normalize } from "path";
import { readFile, writeFile, copyFile } from "fs/promises";
import AdmZip from "adm-zip";
import markdownIt from "markdown-it";
import multimdTable from "markdown-it-multimd-table";
import { z } from "zod";
import {
tool,
text,
type Tool,
type ToolsProviderController
} from "@lmstudio/sdk";
const chatMessageSchema = z.object({
role: z.enum(["user", "assistant", "system"]),
content: z.string(),
timestamp: z.number().optional(),
});
/**
* Required function for protecting Word's XML structure.
* Without it, the code crashes with the error "escapeXml is not defined".
*/
function escapeXml(unsafe: string): string {
if (!unsafe) return "";
return unsafe.replace(/[<>&'"]/g, (c) => {
switch (c) {
case "<": return "<";
case ">": return ">";
case "&": return "&";
case "'": return "'";
case '"': return """;
default: return c;
}
});
}
//-------------------------------------------
function parseMarkdownToDocxXml(tokens: markdownIt.Token[]): string[] {
const resultXml: string[] = [];
// Variables for building regular text strings
let currentRunsXml = "";
let isBold = false;
// === VARIABLES FROM BACKUP FOR TABLE ASSEMBLY ===
let isInsideTable = false;
let tableRowsXml: string[] = [];
let currentRowCellsXml: string[] = [];
let currentCellText = "";
// Helper function to flush accumulated plain text into a paragraph
const flushParagraph = () => {
if (currentRunsXml) {
resultXml.push(`<w:p><w:pPr><w:snapToGrid w:val="0"/></w:pPr>${currentRunsXml}</w:p>`);
currentRunsXml = "";
}
};
for (const token of tokens) {
// ==========================================
// [: TABLES — OPENING]
// ==========================================
if (token.type === "table_open") {
flushParagraph(); // Save text that was before the table
isInsideTable = true;
tableRowsXml = [];
continue;
}
// ==========================================
// [: TABLES — CLOSING]
// ==========================================
if (token.type === "table_close") {
isInsideTable = false;
// Form a ready-made table with visible thin borders
const tableXml = `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:tblBorders><w:top w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:left w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:bottom w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:right w:val="single" w:sz="4" w:space="0" w:color="CCCCCC"/><w:insideH w:val="single" w:sz="4" w:space="0" w:color="E0E0E0"/><w:insideV w:val="single" w:sz="4" w:space="0" w:color="E0E0E0"/></w:tblBorders></w:tblPr>${tableRowsXml.join("")}</w:tbl>`;
resultXml.push(tableXml);
continue;
}
// ==========================================
// [: TABLES — PROCESSING INSIDE]
// ==========================================
if (isInsideTable) {
if (token.type === "tr_close") {
// Row completed, pack cells into <w:tr> tag
tableRowsXml.push(`<w:tr>${currentRowCellsXml.join("")}</w:tr>`);
currentRowCellsXml = [];
}
else if (token.type === "th_close" || token.type === "td_close") {
// Cell completed, create <w:tc>. Text inside must be wrapped in <w:p>
currentRowCellsXml.push(`<w:tc><w:tcPr><w:tcW w:w="0" w:type="auto"/></w:tcPr><w:p><w:r><w:t>${escapeXml(currentCellText)}</w:t></w:r></w:p></w:tc>`);
currentCellText = "";
}
else if (token.type === "inline") {
// Collect text that is inside the table cell
currentCellText += token.content ?? "";
}
continue; // Skip remaining plain text processing logic while we're inside a table
}
// ==========================================
// [: HEADERS AND PLAIN TEXT]
// ==========================================
if (token.type === "heading_open") {
flushParagraph();
const level = token.tag ? token.tag.replace("h", "") : "1";
const nextToken = tokens[tokens.indexOf(token) + 1];
const textContent = nextToken && nextToken.type === "inline" ? nextToken.content : "";
resultXml.push(`<w:p><w:pPr><w:pStyle w:val="Heading${level}"/></w:pPr><w:r><w:t>${escapeXml(textContent)}</w:t></w:r></w:p>`);
continue;
}
if (token.type === "heading_close" || (token.type === "inline" && tokens[tokens.indexOf(token) - 1]?.type === "heading_open")) {
continue;
}
if (token.type === "inline") {
if (token.children) {
for (const child of token.children) {
if (child.type === "strong_open" || child.type === "bold_open") {
isBold = true;
continue;
}
if (child.type === "strong_close" || child.type === "bold_close") {
isBold = false;
continue;
}
if (child.type === "text") {
currentRunsXml += `<w:r><w:rPr>${isBold ? "<w:b/>" : ""}</w:rPr><w:t xml:space="preserve">${escapeXml(child.content)}</w:t></w:r>`;
}
}
} else {
currentRunsXml += `<w:r><w:t xml:space="preserve">${escapeXml(token.content)}</w:t></w:r>`;
}
flushParagraph();
}
}
flushParagraph();
return resultXml;
}
// ---------------------------------------------------------------------------
// Tool: export_to_docx
// ---------------------------------------------------------------------------
export async function toolsProvider(ctl: ToolsProviderController) {
const tools: Tool[] = [];
// Tool#1:
const exportWordTool = tool({
name: "export_to_docx",
description: "Parses the current chat history (messages, roles, tables) and exports the current conversation into a new structured Word (.docx) file with proper table and header formatting using a document template.",
parameters: {
messages: z.array(chatMessageSchema),
filename: z.string().optional(),
},
implementation: async ({ messages, filename }: { messages: z.infer<typeof chatMessageSchema>[], filename?: string }) => {
const workingDirectory = ctl.getWorkingDirectory();
const now = new Date();
// Inside export_to_docx:
const safeTimestamp = now.toISOString().replace(/[:.]/g, "-");
const baseName = filename ?? `chat-export-${safeTimestamp}`;
const finalName = baseName.toLowerCase().endsWith(".docx") ? baseName : `${baseName}.docx`;
const templatePath = normalize(resolve(join(__dirname, "..", "empty_template.json")));
const outputPath = normalize(resolve(join(workingDirectory, finalName)));
const bodyElements: string[] = [];
// Document header
bodyElements.push(`
<w:p>
<w:pPr><w:pStyle w:val="Heading1"/></w:pPr>
<w:r><w:t>EXPORT CHAT -- LM STUDIO</w:t></w:r>
</w:p>`);
bodyElements.push(`<w:p><w:r><w:t>Date: ${escapeXml(now.toLocaleString())}</w:t></w:r></w:p>`);
bodyElements.push(`<w:p><w:r><w:t>Messages count: ${messages.length || 0}</w:t></w:r></w:p>`);
bodyElements.push(`<w:p/>`);
// Filling messages
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!msg || !msg.content) continue;
const role = msg.role === "user" ? "USER" : msg.role === "assistant" ? "ASSISTANT" : "SYSTEM";
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString() : "--:--";
bodyElements.push(`
<w:p>
<w:r><w:rPr><w:b/></w:rPr><w:t>[${i + 1}] ${role} (${time})</w:t></w:r>
</w:p>`);
// (Включаем поддержку GFM таблиц):
const md = markdownIt().use(multimdTable, {
multiline: true,
rowspan: true,
headerless: true
});
const tokens = md.parse(msg.content, {});
const parsedParagraphs = parseMarkdownToDocxXml(tokens);
bodyElements.push(...parsedParagraphs);
bodyElements.push(`<w:p/>`);
}
try {
// 1. Loading your JSON template from the project root
const templatePath = join(__dirname, "..", "empty_template.json");
const zip = new AdmZip(templatePath);
// 2. Reading the ORIGINAL XML template with all namespaces
const originalDocXml = zip.readAsText("word/document.xml", "utf-8");
// 3. Automatic content injection
// Look for the <w:body> tag and insert our generated text right after it
const updatedDocXml = originalDocXml.replace(
"<w:body>",
`<w:body>${bodyElements.join("")}`
);
// 4. Rewriting the modified file into the ZIP archive
zip.updateFile("word/document.xml", Buffer.from(updatedDocXml, "utf-8"));
// 5. Saving the ready-made document to disk
const buffer = zip.toBuffer();
await writeFile(outputPath, buffer);
return { success: true, path: outputPath };
} catch (error: any) {
return { success: false, error: `Failed to export. Template error: ${error.message}` };
}
},
});
tools.push(exportWordTool);
// Tool#2: Appending to an existing file...
// Make sure path-related functions are imported at the top of the file: import { join, isAbsolute } from "path";
const appendParagraphTool = tool({
name: "append_paragraph",
description: "Appends new paragraphs, headers, or formatted tables from Markdown to an existing Word (.docx) document. Accepts both relative filenames, full absolute paths, a plain filename or a full absolute path.",
parameters: {
filename: z.string(), // May be "file.docx" or "C:\\folder\\file.docx"
text: z.string(),
},
implementation: async ({ filename, text }: { filename: string; text: string }) => {
const workingDirectory = ctl.getWorkingDirectory();
// ==========================================
// [: MISSING EXTENSION PROTECTION]
// ==========================================
// Check the input string (it may be a name and/or a full path).
// If it doesn't end in .docx, carefully append it.
const safeFilename = filename.toLowerCase().endsWith(".docx") ? filename : `${filename}.docx`;
// ==========================================
// [: CROSS-PLATFORM PATH]
// ==========================================
// 1. Use the already verified safeFilename.
// If absolute — use as-is; if relative — join with working directory.
const baseTarget = isAbsolute(safeFilename) ? safeFilename : join(workingDirectory, safeFilename);
// 2. Normalize path for the target operating system (Windows / Linux / macOS)
const filePath = normalize(resolve(baseTarget));
try {
// Now AdmZip is guaranteed to receive the correct file path on disk
const zip = new AdmZip(filePath);
// Read current XML content
const originalDocXml = zip.readAsText("word/document.xml", "utf-8");
// Parse new incoming data from Markdown into OpenXML strings
const md = markdownIt().use(multimdTable, { headerless: true });
const tokens = md.parse(text, {});
const newElementsXml = parseMarkdownToDocxXml(tokens).join("");
let updatedDocXml = "";
// ==========================================
// [: XML CONTENT INJECTION]
// ==========================================
if (originalDocXml.includes("<w:sectPr")) {
updatedDocXml = originalDocXml.replace("<w:sectPr", `${newElementsXml}<w:sectPr`);
} else if (originalDocXml.includes("</w:body>")) {
updatedDocXml = originalDocXml.replace("</w:body>", `${newElementsXml}</w:body>`);
} else {
throw new Error("Invalid document structure: <w:body> or <w:sectPr> not found.");
}
// Rewrite and save the archive
zip.updateFile("word/document.xml", Buffer.from(updatedDocXml, "utf-8"));
const buffer = zip.toBuffer();
await writeFile(filePath, buffer);
return { success: true, message: `Successfully appended content to ${filename}` };
} catch (error: any) {
return {
success: false,
error: `Failed to append paragraph to ${filename}. Error: ${error.message}`
};
}
},
});
tools.push(appendParagraphTool);
// Tool#3: Create an empty document — returns the path to a brand new .docx file
const createEmptyDocTool = tool({
name: "create_empty_docx",
description: "Creates a fresh blank Word document (.docx) file based on a valid system template. Use this as a starting point for building documents programmatically. Returns the absolute file path.",
parameters: {
filename: z.string().optional(),
},
implementation: async ({ filename }: { filename?: string }) => {
const workingDirectory = ctl.getWorkingDirectory();
// ==========================================
// [: NAME FORMATION WITHOUT DUPLICATION]
// ==========================================
const now = new Date();
const safeTimestamp = now.toISOString().replace(/[:.]/g, "-");
// Базовое имя: берем то, что дал ИИ, или генерируем дефолтное
const baseName = filename ?? `empty-${safeTimestamp}`;
// ПРОВЕРКА: Если имя уже заканчивается на .docx, оставляем как есть.
// Если нет — аккуратно дописываем расширение.
const finalName = baseName.toLowerCase().endsWith(".docx") ? baseName : `${baseName}.docx`;
// ==========================================
// [: CROSS-PLATFORM PATHS]
// ==========================================
// Now finalName is guaranteed to exist and be correct
const templatePath = normalize(resolve(join(__dirname, "..", "empty_template.json")));
const outputPath = normalize(resolve(join(workingDirectory, finalName)));
try {
// ---------- Simple and reliable file copy ----------
// Copy the ready-made empty template file to the target folder
await copyFile(templatePath, outputPath);
return { success: true, path: outputPath };
} catch (error: any) {
// If you forgot to place the template in the src folder, output a clear error
return {
success: false,
error: `Template file not found at ${templatePath}. Please ensure template.docx is present in the plugin directory.`
};
}
},
});
tools.push(createEmptyDocTool);
return tools;
}
//end.