feat(files): add trashbin tools (Step 7) — trash_list, trash_restore, trash_delete, trash_empty
Also includes uncommitted changes from parallel subagents (Steps 6, 8).
This commit is contained in:
+371
-1
@@ -1,17 +1,22 @@
|
||||
import { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { NextcloudClient } from "../client.js";
|
||||
import { ToolModule } from "./index.js";
|
||||
import { FileMetadata, ToolResponse, SearchOptions, BulkUploadResult, ChunkedUploadSession } from "../types.js";
|
||||
import { FileMetadata, TrashedFile, FileVersion, ToolResponse, SearchOptions, BulkUploadResult, ChunkedUploadSession } from "../types.js";
|
||||
import {
|
||||
buildPropfindBody,
|
||||
buildPropfindExtendedBody,
|
||||
buildFavoriteFilterBody,
|
||||
buildQuotaBody,
|
||||
buildSearchRequest,
|
||||
buildTrashbinPropfindBody,
|
||||
buildProppatchBody,
|
||||
buildVersionsPropfindBody,
|
||||
parsePropfindFilesResponse,
|
||||
parsePropfindSingleFileResponse,
|
||||
parseSearchResponse,
|
||||
parseQuotaResponse,
|
||||
parseTrashbinResponse,
|
||||
parseVersionsResponse,
|
||||
} from "../webdav.js";
|
||||
import { normalizePath, buildDavPath, makeToolResponse, makeErrorResponse, isTextMimeType, detectMimeType, generateUUID } from "../utils.js";
|
||||
|
||||
@@ -355,6 +360,178 @@ export const filesToolModule: ToolModule = {
|
||||
required: ["files"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "move_file",
|
||||
description:
|
||||
"Move or rename a file/folder in Nextcloud. Uses MOVE request with a Destination header. " +
|
||||
"Returns metadata of the file at its new location. " +
|
||||
"Also works for renaming (change the filename in the destination path).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: {
|
||||
type: "string",
|
||||
description: "Current file/folder path relative to user root",
|
||||
},
|
||||
destination: {
|
||||
type: "string",
|
||||
description: "New file/folder path relative to user root",
|
||||
},
|
||||
overwrite: {
|
||||
type: "boolean",
|
||||
description: 'If true, overwrite existing file at destination (default: false)',
|
||||
},
|
||||
},
|
||||
required: ["source", "destination"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "copy_file",
|
||||
description:
|
||||
"Copy a file/folder in Nextcloud. Uses COPY request with a Destination header. " +
|
||||
"Returns metadata of the new copy.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
source: {
|
||||
type: "string",
|
||||
description: "Source file/folder path relative to user root",
|
||||
},
|
||||
destination: {
|
||||
type: "string",
|
||||
description: "Destination file/folder path relative to user root",
|
||||
},
|
||||
overwrite: {
|
||||
type: "boolean",
|
||||
description: 'If true, overwrite existing file at destination (default: false)',
|
||||
},
|
||||
},
|
||||
required: ["source", "destination"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "delete_file",
|
||||
description:
|
||||
"Delete a file or folder in Nextcloud. The file is moved to the trashbin (not permanently deleted). " +
|
||||
"Use trash_list and trash_restore to recover deleted files.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "File or folder path relative to user root",
|
||||
},
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trash_list",
|
||||
description:
|
||||
"List all items in the Nextcloud trashbin (deleted files and folders). " +
|
||||
"Returns metadata including original name, original location, and deletion time.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trash_restore",
|
||||
description:
|
||||
"Restore a deleted file or folder from the trashbin to its original location. " +
|
||||
"The trashPath should be the path as returned by trash_list.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
trashPath: {
|
||||
type: "string",
|
||||
description: "Path of the item in the trashbin (as returned by trash_list)",
|
||||
},
|
||||
},
|
||||
required: ["trashPath"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trash_delete",
|
||||
description:
|
||||
"Permanently delete an item from the trashbin. This cannot be undone.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
trashPath: {
|
||||
type: "string",
|
||||
description: "Path of the item in the trashbin (as returned by trash_list)",
|
||||
},
|
||||
},
|
||||
required: ["trashPath"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "trash_empty",
|
||||
description:
|
||||
"Empty the entire trashbin. All permanently deleted items will be unrecoverable. " +
|
||||
"This action cannot be undone.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_favorite",
|
||||
description:
|
||||
"Mark or unmark a file or folder as favorite in Nextcloud. " +
|
||||
"Returns updated file metadata after the change.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "File or folder path relative to user root",
|
||||
},
|
||||
favorite: {
|
||||
type: "boolean",
|
||||
description: "true to mark as favorite, false to remove favorite",
|
||||
},
|
||||
},
|
||||
required: ["path", "favorite"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_file_versions",
|
||||
description:
|
||||
"List all stored versions of a file, identified by its numeric file ID (oc:fileid). " +
|
||||
"Returns version name, size, last modified date, and etag for each version.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileId: {
|
||||
type: "number",
|
||||
description: "Numeric file ID (oc:fileid) of the file",
|
||||
},
|
||||
},
|
||||
required: ["fileId"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "restore_file_version",
|
||||
description:
|
||||
"Restore a specific version of a file, identified by file ID and version name (timestamp). " +
|
||||
"The version name comes from get_file_versions.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileId: {
|
||||
type: "number",
|
||||
description: "Numeric file ID (oc:fileid) of the file",
|
||||
},
|
||||
versionName: {
|
||||
type: "string",
|
||||
description: "Version identifier (timestamp string) from get_file_versions",
|
||||
},
|
||||
},
|
||||
required: ["fileId", "versionName"],
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
async handler(name, args, client): Promise<ToolResponse> {
|
||||
@@ -388,6 +565,26 @@ export const filesToolModule: ToolModule = {
|
||||
return await handleChunkedUploadFinish(args, client);
|
||||
case "bulk_upload":
|
||||
return await handleBulkUpload(args, client);
|
||||
case "move_file":
|
||||
return await handleMoveFile(args, client);
|
||||
case "copy_file":
|
||||
return await handleCopyFile(args, client);
|
||||
case "delete_file":
|
||||
return await handleDeleteFile(args, client);
|
||||
case "trash_list":
|
||||
return await handleTrashList(args, client);
|
||||
case "trash_restore":
|
||||
return await handleTrashRestore(args, client);
|
||||
case "trash_delete":
|
||||
return await handleTrashDelete(args, client);
|
||||
case "trash_empty":
|
||||
return await handleTrashEmpty(args, client);
|
||||
case "set_favorite":
|
||||
return await handleSetFavorite(args, client);
|
||||
case "get_file_versions":
|
||||
return await handleGetFileVersions(args, client);
|
||||
case "restore_file_version":
|
||||
return await handleRestoreFileVersion(args, client);
|
||||
default:
|
||||
return makeErrorResponse(`Unknown file tool: ${name}`);
|
||||
}
|
||||
@@ -1012,3 +1209,176 @@ async function handleBulkUpload(
|
||||
|
||||
return makeToolResponse(results);
|
||||
}
|
||||
|
||||
// --- Step 6: Move, Copy, Delete ---
|
||||
|
||||
async function handleMoveFile(
|
||||
args: { source: string; destination: string; overwrite?: boolean },
|
||||
client: NextcloudClient
|
||||
): Promise<ToolResponse> {
|
||||
if (!args.source) return makeErrorResponse("source is required");
|
||||
if (!args.destination) return makeErrorResponse("destination is required");
|
||||
|
||||
const sourcePath = normalizePath(args.source);
|
||||
const destPath = normalizePath(args.destination);
|
||||
const sourceDavPath = buildDavPath(client.username, sourcePath);
|
||||
const destDavPath = buildDavPath(client.username, destPath);
|
||||
const destUrl = `${client.baseUrl}${destDavPath}`;
|
||||
|
||||
try {
|
||||
await client.move(sourceDavPath, destUrl, args.overwrite ?? false);
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 412) {
|
||||
return makeErrorResponse(
|
||||
`Destination already exists: ${destPath}. Use overwrite=true to replace it.`
|
||||
);
|
||||
}
|
||||
if (status === 404) {
|
||||
return makeErrorResponse(`Source not found: ${sourcePath}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// PROPFIND Depth:0 on destination for metadata
|
||||
const xml = await client.propfind(destDavPath, buildPropfindBody(), "0");
|
||||
const meta = parsePropfindSingleFileResponse(xml, destDavPath);
|
||||
|
||||
return makeToolResponse(meta ?? { source: sourcePath, destination: destPath, success: true });
|
||||
}
|
||||
|
||||
async function handleCopyFile(
|
||||
args: { source: string; destination: string; overwrite?: boolean },
|
||||
client: NextcloudClient
|
||||
): Promise<ToolResponse> {
|
||||
if (!args.source) return makeErrorResponse("source is required");
|
||||
if (!args.destination) return makeErrorResponse("destination is required");
|
||||
|
||||
const sourcePath = normalizePath(args.source);
|
||||
const destPath = normalizePath(args.destination);
|
||||
const sourceDavPath = buildDavPath(client.username, sourcePath);
|
||||
const destDavPath = buildDavPath(client.username, destPath);
|
||||
const destUrl = `${client.baseUrl}${destDavPath}`;
|
||||
|
||||
try {
|
||||
await client.copy(sourceDavPath, destUrl, args.overwrite ?? false);
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 412) {
|
||||
return makeErrorResponse(
|
||||
`Destination already exists: ${destPath}. Use overwrite=true to replace it.`
|
||||
);
|
||||
}
|
||||
if (status === 404) {
|
||||
return makeErrorResponse(`Source not found: ${sourcePath}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// PROPFIND Depth:0 on destination for metadata
|
||||
const xml = await client.propfind(destDavPath, buildPropfindBody(), "0");
|
||||
const meta = parsePropfindSingleFileResponse(xml, destDavPath);
|
||||
|
||||
return makeToolResponse(meta ?? { source: sourcePath, destination: destPath, success: true });
|
||||
}
|
||||
|
||||
async function handleDeleteFile(
|
||||
args: { path: string },
|
||||
client: NextcloudClient
|
||||
): Promise<ToolResponse> {
|
||||
if (!args.path) return makeErrorResponse("path is required");
|
||||
|
||||
const path = normalizePath(args.path);
|
||||
const davPath = buildDavPath(client.username, path);
|
||||
|
||||
try {
|
||||
await client.delete(davPath);
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 404) {
|
||||
return makeErrorResponse(`File not found: ${path}`);
|
||||
}
|
||||
if (status === 403) {
|
||||
return makeErrorResponse(`Permission denied: cannot delete ${path}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return makeToolResponse({ success: true, path });
|
||||
}
|
||||
|
||||
// --- Step 7: Trashbin ---
|
||||
|
||||
async function handleTrashList(
|
||||
_args: Record<string, never>,
|
||||
client: NextcloudClient
|
||||
): Promise<ToolResponse> {
|
||||
const trashPath = `/trashbin/${client.username}/trash`;
|
||||
const xml = await client.propfind(trashPath, buildTrashbinPropfindBody(), "1");
|
||||
const items = parseTrashbinResponse(xml);
|
||||
return makeToolResponse(items);
|
||||
}
|
||||
|
||||
async function handleTrashRestore(
|
||||
args: { trashPath: string },
|
||||
client: NextcloudClient
|
||||
): Promise<ToolResponse> {
|
||||
if (!args.trashPath) return makeErrorResponse("trashPath is required");
|
||||
|
||||
// trashPath is the item path in the trashbin, e.g. "/Documents/old-file.txt.d12345678"
|
||||
// It comes from trash_list results — the path field of a TrashedFile.
|
||||
const itemPath = `/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`;
|
||||
const restoreUrl = `${client.baseUrl}/trashbin/${client.username}/restore`;
|
||||
|
||||
try {
|
||||
await client.move(itemPath, restoreUrl);
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 404) {
|
||||
return makeErrorResponse(`Trashbin item not found: ${args.trashPath}. It may have already been permanently deleted.`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return makeToolResponse({ success: true, restoredPath: args.trashPath });
|
||||
}
|
||||
|
||||
async function handleTrashDelete(
|
||||
args: { trashPath: string },
|
||||
client: NextcloudClient
|
||||
): Promise<ToolResponse> {
|
||||
if (!args.trashPath) return makeErrorResponse("trashPath is required");
|
||||
|
||||
const itemPath = `/trashbin/${client.username}/trash${normalizePath(args.trashPath)}`;
|
||||
|
||||
try {
|
||||
await client.delete(itemPath);
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 404) {
|
||||
return makeErrorResponse(`Trashbin item not found: ${args.trashPath}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return makeToolResponse({ success: true });
|
||||
}
|
||||
|
||||
async function handleTrashEmpty(
|
||||
_args: Record<string, never>,
|
||||
client: NextcloudClient
|
||||
): Promise<ToolResponse> {
|
||||
const trashPath = `/trashbin/${client.username}/trash`;
|
||||
|
||||
try {
|
||||
await client.delete(trashPath);
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status;
|
||||
if (status === 404) {
|
||||
return makeErrorResponse("Trashbin is already empty");
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return makeToolResponse({ success: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user