mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Introduce a single configurable `copilotFolder` root (default `copilot`) that
all six Copilot sub-folders derive from (`${root}/copilot-conversations`,
`/copilot-custom-prompts`, `/system-prompts`, `/skills`, `/memory`,
`/projects`). Retire the three separate sub-folder settings and the
conversation-tag setting; the plugin never moves files — changing the root (or
upgrading with customized sub-folders) reloads consumers to the derived
location, prompts the user once about old→new paths, and leaves existing data
in place.
Core:
- copilotFolder field + v8 migration (seeds root, captures a legacy-upgrade
flag for pre-versioned installs).
- Pure derived accessors + reuse of the existing subscribe/debounce/reload
watcher pattern (watchers compare derived paths); custom-command watcher
added with pure-fetch → generation-check → atomic swap.
- QA privacy: whole-root exclusion expanded from `copilot` to
`copilot` + active root + all historical roots across all consumer entry
points; reset/hydrate preserve root history.
- Editable root UI (draft + explicit Apply), change-root ConfirmModal +
atomic landing + best-effort GC, root validation; conversation tag frozen to
a constant; skills folder is root-derived (no settings row).
- One-time v3→v4 relocation prompt for customized users.
Docs updated; retired fields kept as dead (no schema break). Tag freeze is
zero-migration (session identity is folder+epoch, never the tag).
530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
import {
|
|
COPILOT_COMMAND_CONTEXT_MENU_ENABLED,
|
|
COPILOT_COMMAND_CONTEXT_MENU_ORDER,
|
|
COPILOT_COMMAND_LAST_USED,
|
|
COPILOT_COMMAND_MODEL_KEY,
|
|
COPILOT_COMMAND_SLASH_ENABLED,
|
|
EMPTY_COMMAND,
|
|
LEGACY_SELECTED_TEXT_PLACEHOLDER,
|
|
} from "@/commands/constants";
|
|
import { CustomCommand } from "@/commands/type";
|
|
import { logWarn } from "@/logger";
|
|
import { App, Notice, TAbstractFile, TFile, Vault } from "obsidian";
|
|
import { getSettings } from "@/settings/model";
|
|
import { getEffectiveCustomPromptsFolder } from "@/settings/copilotFolder";
|
|
import {
|
|
updateCachedCommands,
|
|
getCachedCustomCommands,
|
|
addPendingFileWrite,
|
|
removePendingFileWrite,
|
|
} from "./state";
|
|
import { PromptSortStrategy } from "@/types";
|
|
import {
|
|
extractTemplateNoteFiles,
|
|
getFileContent,
|
|
getFileName,
|
|
getNotesFromPath,
|
|
getNotesFromTags,
|
|
processVariableNameForNotePath,
|
|
stripFrontmatter,
|
|
} from "@/utils";
|
|
import { sortByStrategy } from "@/utils/recentUsageManager";
|
|
import {
|
|
NOTE_CONTEXT_PROMPT_TAG,
|
|
SELECTED_TEXT_TAG,
|
|
VARIABLE_TAG,
|
|
VARIABLE_NOTE_TAG,
|
|
} from "@/constants";
|
|
|
|
export function validateCommandName(
|
|
name: string,
|
|
commands: CustomCommand[],
|
|
currentCommandName?: string
|
|
): string | null {
|
|
const trimmedName = name.trim();
|
|
|
|
if (!trimmedName) {
|
|
return "Command name cannot be empty";
|
|
}
|
|
|
|
if (name !== trimmedName) {
|
|
return "Command name cannot have leading or trailing spaces";
|
|
}
|
|
|
|
if (currentCommandName && name === currentCommandName) {
|
|
return null; // No change needed
|
|
}
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
const invalidChars = /[#<>:"/\\|?*[\]^\x00-\x1F]/g;
|
|
if (invalidChars.test(trimmedName)) {
|
|
return 'Command name contains invalid characters. Avoid using: < > : " / \\ | ? * [ ] ^';
|
|
}
|
|
|
|
if (commands.some((cmd) => cmd.title.toLowerCase() === trimmedName.toLowerCase())) {
|
|
return "A command with this name already exists";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Converts a custom command name to a command id. Encodes the name to avoid
|
|
* special characters.
|
|
*/
|
|
export function getCommandId(commandName: string) {
|
|
return encodeURIComponent(commandName.toLowerCase());
|
|
}
|
|
|
|
export function getCustomCommandsFolder(): string {
|
|
return getEffectiveCustomPromptsFolder();
|
|
}
|
|
|
|
export function getCommandFilePath(title: string): string {
|
|
return `${getCustomCommandsFolder()}/${title}.md`;
|
|
}
|
|
|
|
/**
|
|
* Check if a file is a markdown file in the custom commands folder.
|
|
*/
|
|
export function isCustomCommandFile(file: TAbstractFile): boolean {
|
|
if (!(file instanceof TFile)) return false;
|
|
if (file.extension !== "md") return false;
|
|
const folder = getCustomCommandsFolder();
|
|
if (!file.path.startsWith(folder + "/")) return false;
|
|
// Only include direct children (no slashes in relative path)
|
|
const relativePath = file.path.slice(folder.length + 1);
|
|
if (relativePath.includes("/")) return false;
|
|
return true;
|
|
}
|
|
|
|
export function hasOrderFrontmatter(app: App, file: TFile): boolean {
|
|
const metadata = app.metadataCache.getFileCache(file);
|
|
return metadata?.frontmatter?.[COPILOT_COMMAND_CONTEXT_MENU_ORDER] != null;
|
|
}
|
|
|
|
/**
|
|
* Parse a TFile as a CustomCommand by reading its content and extracting frontmatter.
|
|
*/
|
|
export async function parseCustomCommandFile(app: App, file: TFile): Promise<CustomCommand> {
|
|
const rawContent = await app.vault.read(file);
|
|
const content = stripFrontmatter(rawContent);
|
|
const metadata = app.metadataCache.getFileCache(file);
|
|
const showInContextMenu =
|
|
metadata?.frontmatter?.[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] ??
|
|
EMPTY_COMMAND.showInContextMenu;
|
|
const showInSlashMenu =
|
|
metadata?.frontmatter?.[COPILOT_COMMAND_SLASH_ENABLED] ?? EMPTY_COMMAND.showInSlashMenu;
|
|
const lastUsedMs = metadata?.frontmatter?.[COPILOT_COMMAND_LAST_USED] ?? EMPTY_COMMAND.lastUsedMs;
|
|
const order = metadata?.frontmatter?.[COPILOT_COMMAND_CONTEXT_MENU_ORDER] ?? EMPTY_COMMAND.order;
|
|
const modelKey = metadata?.frontmatter?.[COPILOT_COMMAND_MODEL_KEY] ?? EMPTY_COMMAND.modelKey;
|
|
|
|
return {
|
|
title: file.basename,
|
|
modelKey,
|
|
content,
|
|
showInContextMenu,
|
|
showInSlashMenu,
|
|
order,
|
|
lastUsedMs,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Fetch all custom commands from the vault WITHOUT writing the global cache.
|
|
* Use this when the caller must coordinate cache writes itself (e.g. latest-wins
|
|
* folder reloads) so a superseded async reload cannot clobber a newer result.
|
|
*/
|
|
export async function fetchAllCustomCommands(app: App): Promise<CustomCommand[]> {
|
|
const files = app.vault.getFiles().filter((file) => isCustomCommandFile(file));
|
|
return await Promise.all(files.map((file) => parseCustomCommandFile(app, file)));
|
|
}
|
|
|
|
export async function loadAllCustomCommands(app: App): Promise<CustomCommand[]> {
|
|
const commands = await fetchAllCustomCommands(app);
|
|
updateCachedCommands(commands);
|
|
return commands;
|
|
}
|
|
|
|
export function sortCommandsByOrder(commands: CustomCommand[]): CustomCommand[] {
|
|
return sortByStrategy(commands, "manual", {
|
|
getName: (command) => command.title,
|
|
getCreatedAtMs: () => 0,
|
|
getLastUsedAtMs: () => 0,
|
|
getManualOrder: (command) => command.order,
|
|
});
|
|
}
|
|
|
|
function sortCommandsByRecency(commands: CustomCommand[]): CustomCommand[] {
|
|
return sortByStrategy(commands, "recent", {
|
|
getName: (command) => command.title,
|
|
getCreatedAtMs: () => 0,
|
|
getLastUsedAtMs: (command) => command.lastUsedMs,
|
|
});
|
|
}
|
|
|
|
function sortCommandsByAlphabetical(commands: CustomCommand[]): CustomCommand[] {
|
|
return sortByStrategy(commands, "name", {
|
|
getName: (command) => command.title,
|
|
getCreatedAtMs: () => 0,
|
|
getLastUsedAtMs: () => 0,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sort prompts of the slash commands based on the sort strategy.
|
|
*/
|
|
export function sortSlashCommands(commands: CustomCommand[]): CustomCommand[] {
|
|
const sortStrategy: PromptSortStrategy = getSettings().promptSortStrategy as PromptSortStrategy;
|
|
switch (sortStrategy) {
|
|
case PromptSortStrategy.TIMESTAMP:
|
|
return sortCommandsByRecency(commands);
|
|
case PromptSortStrategy.ALPHABETICAL:
|
|
return sortCommandsByAlphabetical(commands);
|
|
case PromptSortStrategy.MANUAL:
|
|
return sortCommandsByOrder(commands);
|
|
default:
|
|
return commands;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process the custom command prompt. In addition to the regular prompt processing,
|
|
* it handles legacy logic such as auto appending the selected text to the prompt
|
|
* if it's not already present.
|
|
*/
|
|
export async function processCommandPrompt(
|
|
app: App,
|
|
prompt: string,
|
|
selectedText: string,
|
|
skipAppendingSelectedText = false
|
|
) {
|
|
const result = await processPrompt(
|
|
app,
|
|
prompt,
|
|
selectedText,
|
|
app.vault,
|
|
app.workspace.getActiveFile()
|
|
);
|
|
|
|
const processedPrompt = result.processedPrompt;
|
|
|
|
if (processedPrompt.includes(`{${SELECTED_TEXT_TAG}}`) || skipAppendingSelectedText) {
|
|
// Containing {selected_text} means the prompt was using the custom prompt
|
|
// processor way of handling the selected text. No need to go through the
|
|
// legacy placeholder.
|
|
return processedPrompt;
|
|
}
|
|
|
|
// This is the legacy custom command selected text placeholder. It replaced
|
|
// {copilot-selection} in the prompt with the selected text. This is different
|
|
// from the custom prompt processor which uses {} in the prompt and appends
|
|
// the selected text to the prompt. We cannot change user's custom commands
|
|
// that have the old placeholder, so we need to support both.
|
|
// Also, selected text is required for custom commands. If neither `{}` nor
|
|
// `{copilot-selection}` is found, append the selected text to the prompt.
|
|
const index = processedPrompt.indexOf(LEGACY_SELECTED_TEXT_PLACEHOLDER);
|
|
if (index === -1) {
|
|
// No legacy placeholder found
|
|
if (selectedText.trim()) {
|
|
// Append selected text if present
|
|
return (
|
|
processedPrompt +
|
|
"\n\n<" +
|
|
SELECTED_TEXT_TAG +
|
|
">" +
|
|
selectedText +
|
|
"</" +
|
|
SELECTED_TEXT_TAG +
|
|
">"
|
|
);
|
|
}
|
|
// No placeholder and no selected text - return as is
|
|
return processedPrompt;
|
|
}
|
|
// Replace legacy placeholder with selected text
|
|
return (
|
|
processedPrompt.slice(0, index) +
|
|
selectedText +
|
|
processedPrompt.slice(index + LEGACY_SELECTED_TEXT_PLACEHOLDER.length)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Find all variables between {} in a custom command prompt.
|
|
* {copilot-selection} is the legacy custom command special placeholder. It must
|
|
* be skipped when processing custom prompts because it's handled differently
|
|
* by the custom command prompt processor.
|
|
*
|
|
* Also excludes {[[...]]} patterns which are handled separately by extractTemplateNoteFiles.
|
|
*/
|
|
const VARIABLE_REGEX = /\{(?!copilot-selection\}|\[\[)([^}]+)\}/g;
|
|
|
|
/**
|
|
* Represents the result of processing a custom prompt variable.
|
|
*/
|
|
interface VariableProcessingResult {
|
|
content: string;
|
|
files: TFile[];
|
|
}
|
|
|
|
/**
|
|
* Extract variables from a custom prompt and get their content and associated
|
|
* files.
|
|
*/
|
|
async function extractVariablesFromPrompt(
|
|
app: App,
|
|
customPrompt: string,
|
|
vault: Vault,
|
|
activeNote?: TFile | null
|
|
): Promise<{ variablesMap: Map<string, string>; includedFiles: Set<TFile> }> {
|
|
const variablesMap = new Map<string, string>();
|
|
const includedFiles = new Set<TFile>();
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = VARIABLE_REGEX.exec(customPrompt)) !== null) {
|
|
const variableName = match[1].trim();
|
|
const variableResult: VariableProcessingResult = { content: "", files: [] };
|
|
|
|
const variableNameLower = variableName.toLowerCase();
|
|
|
|
if (variableNameLower === "activenote") {
|
|
if (activeNote) {
|
|
const content = await getFileContent(activeNote, vault);
|
|
if (content) {
|
|
variableResult.content = `<${VARIABLE_NOTE_TAG}>\n<path>${activeNote.path}</path>\n## ${getFileName(activeNote)}\n\n${content}\n</${VARIABLE_NOTE_TAG}>`;
|
|
variableResult.files.push(activeNote);
|
|
}
|
|
} else {
|
|
new Notice("No active note found.");
|
|
}
|
|
} else if (variableNameLower === "activewebtab") {
|
|
// Reserved variable: handled by webTabs context pipeline, skip here
|
|
continue;
|
|
} else if (variableName.startsWith("#")) {
|
|
// Handle tag-based variable for multiple tags
|
|
const tagNames = variableName
|
|
.slice(1)
|
|
.split(",")
|
|
.map((tag) => tag.trim());
|
|
const noteFiles = getNotesFromTags(app, tagNames);
|
|
const notesContent: string[] = [];
|
|
for (const file of noteFiles) {
|
|
const content = await getFileContent(file, vault);
|
|
if (content) {
|
|
notesContent.push(
|
|
`<${VARIABLE_NOTE_TAG}>\n<path>${file.path}</path>\n## ${getFileName(file)}\n\n${content}\n</${VARIABLE_NOTE_TAG}>`
|
|
);
|
|
variableResult.files.push(file);
|
|
}
|
|
}
|
|
variableResult.content = notesContent.join("\n\n");
|
|
} else {
|
|
const processedVariableName = processVariableNameForNotePath(variableName);
|
|
const noteFiles = getNotesFromPath(vault, processedVariableName);
|
|
const notesContent: string[] = [];
|
|
for (const file of noteFiles) {
|
|
const content = await getFileContent(file, vault);
|
|
if (content) {
|
|
notesContent.push(
|
|
`<${VARIABLE_NOTE_TAG}>\n<path>${file.path}</path>\n## ${getFileName(file)}\n\n${content}\n</${VARIABLE_NOTE_TAG}>`
|
|
);
|
|
variableResult.files.push(file);
|
|
}
|
|
}
|
|
variableResult.content = notesContent.join("\n\n");
|
|
}
|
|
|
|
if (variableResult.content) {
|
|
variablesMap.set(variableName, variableResult.content);
|
|
variableResult.files.forEach((file) => includedFiles.add(file));
|
|
} else if (variableNameLower !== "activenote" && variableNameLower !== "activewebtab") {
|
|
if (variableName.startsWith('"')) {
|
|
// DO NOTHING as the user probably wants to write a JSON object
|
|
} else {
|
|
logWarn(`No notes found for variable: ${variableName}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { variablesMap, includedFiles };
|
|
}
|
|
|
|
/**
|
|
* Represents the result of processing a custom prompt.
|
|
*/
|
|
export interface ProcessedPromptResult {
|
|
processedPrompt: string;
|
|
includedFiles: TFile[];
|
|
}
|
|
|
|
/**
|
|
* Process a custom prompt by replacing variables and adding note contents.
|
|
* Returns the processed prompt string and a list of files included in the processing.
|
|
*
|
|
* @param skipEmptyBraces - When true, treats `{}` as a literal and skips selected-text/active-note expansion.
|
|
*/
|
|
export async function processPrompt(
|
|
app: App,
|
|
customPrompt: string,
|
|
selectedText: string,
|
|
vault: Vault,
|
|
activeNote?: TFile | null,
|
|
skipEmptyBraces: boolean = false
|
|
): Promise<ProcessedPromptResult> {
|
|
const settings = getSettings();
|
|
const includedFiles = new Set<TFile>();
|
|
|
|
if (!settings.enableCustomPromptTemplating) {
|
|
// If templating is disabled, check if activeNote should be included for {}
|
|
if (!skipEmptyBraces && customPrompt.includes("{}") && !selectedText && activeNote) {
|
|
includedFiles.add(activeNote);
|
|
}
|
|
return {
|
|
processedPrompt: customPrompt + "\n\n",
|
|
includedFiles: Array.from(includedFiles),
|
|
};
|
|
}
|
|
|
|
// Extract variables and track files included through them
|
|
const { variablesMap, includedFiles: variableFiles } = await extractVariablesFromPrompt(
|
|
app,
|
|
customPrompt,
|
|
vault,
|
|
activeNote
|
|
);
|
|
variableFiles.forEach((file) => includedFiles.add(file));
|
|
|
|
let processedPrompt = customPrompt;
|
|
let additionalInfo = "";
|
|
let activeNoteContent: string | null = null;
|
|
|
|
if (!skipEmptyBraces && processedPrompt.includes("{}")) {
|
|
processedPrompt = processedPrompt.replace(/\{\}/g, `{${SELECTED_TEXT_TAG}}`);
|
|
if (selectedText) {
|
|
additionalInfo += `<${SELECTED_TEXT_TAG}>\n${selectedText}\n</${SELECTED_TEXT_TAG}>`;
|
|
// Note: selectedText doesn't directly correspond to a file inclusion here
|
|
} else if (activeNote) {
|
|
activeNoteContent = await getFileContent(activeNote, vault);
|
|
additionalInfo += `<${SELECTED_TEXT_TAG} type="active_note">\n${activeNoteContent || ""}\n</${SELECTED_TEXT_TAG}>`;
|
|
includedFiles.add(activeNote); // Ensure active note is tracked if used for {}
|
|
} else {
|
|
additionalInfo += `<${SELECTED_TEXT_TAG}>\n(No selected text or active note available)\n</${SELECTED_TEXT_TAG}>`;
|
|
}
|
|
}
|
|
|
|
// Add variable contents to the additional info
|
|
// The files are already tracked via includedFiles set
|
|
for (const [varName, content] of variablesMap.entries()) {
|
|
if (varName.toLowerCase() === "activenote" && activeNoteContent !== null) {
|
|
// Content already added via {} handling, but file tracking is done.
|
|
continue;
|
|
}
|
|
if (additionalInfo) {
|
|
additionalInfo += `\n\n<${VARIABLE_TAG} name="${varName}">\n${content}\n</${VARIABLE_TAG}>`;
|
|
} else {
|
|
additionalInfo += `<${VARIABLE_TAG} name="${varName}">\n${content}\n</${VARIABLE_TAG}>`;
|
|
}
|
|
}
|
|
|
|
// Process {[[note title]]} syntax - only wikilinks wrapped in curly braces
|
|
const noteLinkFiles = extractTemplateNoteFiles(processedPrompt, vault);
|
|
for (const noteFile of noteLinkFiles) {
|
|
// Check if this note wasn't already included via a variable
|
|
// We use the Set's reference equality which works for TFile objects
|
|
if (!includedFiles.has(noteFile)) {
|
|
const noteContent = await getFileContent(noteFile, vault);
|
|
if (noteContent) {
|
|
// Get file metadata
|
|
const stats = await vault.adapter.stat(noteFile.path);
|
|
const ctime = stats ? new Date(stats.ctime).toISOString() : "Unknown";
|
|
const mtime = stats ? new Date(stats.mtime).toISOString() : "Unknown";
|
|
|
|
const noteContext = `<${NOTE_CONTEXT_PROMPT_TAG}>
|
|
<title>${noteFile.basename}</title>
|
|
<path>${noteFile.path}</path>
|
|
<ctime>${ctime}</ctime>
|
|
<mtime>${mtime}</mtime>
|
|
<content>
|
|
${noteContent}
|
|
</content>
|
|
</${NOTE_CONTEXT_PROMPT_TAG}>`;
|
|
if (additionalInfo) {
|
|
additionalInfo += `\n\n`;
|
|
}
|
|
additionalInfo += `${noteContext}`;
|
|
includedFiles.add(noteFile); // Track files included via [[links]]
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
processedPrompt: additionalInfo
|
|
? `${processedPrompt}\n\n${additionalInfo}`
|
|
: `${processedPrompt}\n\n`,
|
|
includedFiles: Array.from(includedFiles),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Generates a unique name for a copied command by adding "(copy)" or "(copy N)" suffix.
|
|
*/
|
|
export function generateCopyCommandName(
|
|
originalName: string,
|
|
existingCommands: CustomCommand[]
|
|
): string {
|
|
const baseName = `${originalName} (copy)`;
|
|
let copyName = baseName;
|
|
let counter = 1;
|
|
|
|
// Check if the base copy name already exists
|
|
while (existingCommands.some((cmd) => cmd.title.toLowerCase() === copyName.toLowerCase())) {
|
|
counter++;
|
|
copyName = `${originalName} (copy ${counter})`;
|
|
}
|
|
|
|
return copyName;
|
|
}
|
|
|
|
/**
|
|
* Returns the next order value for a new custom command, based on the cached commands.
|
|
* If the last order is Number.MAX_SAFE_INTEGER, returns Number.MAX_SAFE_INTEGER.
|
|
*/
|
|
export function getNextCustomCommandOrder(): number {
|
|
const commands = getCachedCustomCommands();
|
|
const lastOrder = commands.reduce(
|
|
(prev: number, curr: CustomCommand) => (prev > curr.order ? prev : curr.order),
|
|
0
|
|
);
|
|
return lastOrder === Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : lastOrder + 10;
|
|
}
|
|
|
|
/**
|
|
* Ensures that the required frontmatter fields exist on the given file. Only
|
|
* adds missing fields, does not overwrite existing values.
|
|
* This is idempotent and does not touch the file content.
|
|
*/
|
|
export async function ensureCommandFrontmatter(app: App, file: TFile, command: CustomCommand) {
|
|
try {
|
|
addPendingFileWrite(file.path);
|
|
await app.fileManager.processFrontMatter(file, (frontmatter: Record<string, unknown>) => {
|
|
if (frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] == null) {
|
|
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ENABLED] = command.showInContextMenu;
|
|
}
|
|
if (frontmatter[COPILOT_COMMAND_SLASH_ENABLED] == null) {
|
|
frontmatter[COPILOT_COMMAND_SLASH_ENABLED] = command.showInSlashMenu;
|
|
}
|
|
if (frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] == null) {
|
|
frontmatter[COPILOT_COMMAND_CONTEXT_MENU_ORDER] = command.order;
|
|
}
|
|
if (frontmatter[COPILOT_COMMAND_MODEL_KEY] == null) {
|
|
frontmatter[COPILOT_COMMAND_MODEL_KEY] = command.modelKey;
|
|
}
|
|
if (frontmatter[COPILOT_COMMAND_LAST_USED] == null) {
|
|
frontmatter[COPILOT_COMMAND_LAST_USED] = command.lastUsedMs;
|
|
}
|
|
});
|
|
} finally {
|
|
removePendingFileWrite(file.path);
|
|
}
|
|
}
|