Skip to content

Commit

Permalink
feat(chat/actions): ability to move files
Browse files Browse the repository at this point in the history
  • Loading branch information
benjaminshafii committed Dec 2, 2024
1 parent f2e4e53 commit 38ecd7f
Show file tree
Hide file tree
Showing 5 changed files with 261 additions and 36 deletions.
2 changes: 1 addition & 1 deletion plugin/views/ai-chat/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const ChatComponent: React.FC<ChatComponentProps> = ({
logger.debug("contextString", contextString);

const chatBody = {
currentDatetime: moment().format("YYYY-MM-DDTHH:mm:ssZ"),
currentDatetime: window.moment().format("YYYY-MM-DDTHH:mm:ssZ"),
enableScreenpipe: plugin.settings.enableScreenpipe,
newUnifiedContext: contextString,
};
Expand Down
167 changes: 167 additions & 0 deletions plugin/views/ai-chat/tool-handlers/move-files-handler.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import React, { useState } from "react";
import { TFile } from "obsidian";
import { usePlugin } from "../../organizer/provider";
import { ToolHandlerProps } from "./types";

interface FilePattern {
namePattern?: string;
extension?: string;
}

interface MoveOperation {
sourcePath: string;
destinationPath: string;
pattern?: FilePattern;
}

export function MoveFilesHandler({
toolInvocation,
handleAddResult,
}: ToolHandlerProps) {
const plugin = usePlugin();
const [isValidated, setIsValidated] = useState(false);
const [moveResults, setMoveResults] = useState<string[]>([]);
const [filesToMove, setFilesToMove] = useState<TFile[]>([]);

// Simplified pattern matching without isRoot
const matchesPattern = (file: TFile, pattern?: FilePattern): boolean => {
if (!pattern) return true;

const { namePattern, extension } = pattern;

// Check file name pattern
if (namePattern) {
const regex = new RegExp(namePattern.replace("*", ".*"));
if (!regex.test(file.basename)) {
return false;
}
}

// Check extension
if (extension && !file.extension.toLowerCase().includes(extension.toLowerCase())) {
return false;
}

return true;
};

// Simplified file matching using sourcePath
const getMatchingFiles = (moveOp: MoveOperation): TFile[] => {
const allFiles = plugin.app.vault.getMarkdownFiles();

return allFiles.filter(file => {
// For root path, only match files directly in root
if (moveOp.sourcePath === "/") {
return !file.path.includes("/") && matchesPattern(file, moveOp.pattern);
}

// For specific source paths
return file.path.startsWith(moveOp.sourcePath) && matchesPattern(file, moveOp.pattern);
});
};

React.useEffect(() => {
if (!isValidated && !filesToMove.length) {
const { moves } = toolInvocation.args;
const matchedFiles = moves.flatMap(move => getMatchingFiles(move));
setFilesToMove(matchedFiles);
}
}, [toolInvocation.args, isValidated]);

const handleMoveFiles = async () => {
const { moves } = toolInvocation.args;
const results: string[] = [];

for (const move of moves) {
try {
// Get matching files for this move operation
const matchingFiles = getMatchingFiles(move);

// Create destination folder if it doesn't exist
await plugin.app.vault.createFolder(move.destinationPath).catch(() => {});

// Move each matching file
for (const file of matchingFiles) {
const newPath = `${move.destinationPath}/${file.name}`;
await plugin.app.fileManager.renameFile(file, newPath);
results.push(`✅ Moved: ${file.path}${newPath}`);
}

if (matchingFiles.length === 0) {
results.push(`ℹ️ No files found matching criteria for ${move.sourcePath}`);
}
} catch (error) {
results.push(`❌ Error: ${error.message}`);
}
}

setMoveResults(results);
setIsValidated(true);
handleAddResult(JSON.stringify({ success: true, results }));
};

return (
<div className="flex flex-col space-y-4 p-4 border border-[--background-modifier-border] rounded-md">
<div className="text-[--text-normal]">
{toolInvocation.args.message || "Ready to move files"}
</div>

{!isValidated && filesToMove.length > 0 && (
<div className="text-sm text-[--text-muted]">
Found {filesToMove.length} files to move:
<ul className="list-disc ml-4 mt-1">
{filesToMove.slice(0, 5).map((file, i) => (
<li key={i}>{file.path}</li>
))}
{filesToMove.length > 5 && (
<li>...and {filesToMove.length - 5} more</li>
)}
</ul>
</div>
)}

{moveResults.length > 0 && (
<div className="text-sm space-y-1">
{moveResults.map((result, i) => (
<div
key={i}
className={`${
result.startsWith("✅")
? "text-[--text-success]"
: result.startsWith("ℹ️")
? "text-[--text-muted]"
: "text-[--text-error]"
}`}
>
{result}
</div>
))}
</div>
)}

{!isValidated && (
<div className="flex space-x-2">
<button
onClick={handleMoveFiles}
className="px-4 py-2 bg-[--interactive-accent] text-[--text-on-accent] rounded-md hover:bg-[--interactive-accent-hover]"
>
Move {filesToMove.length} Files
</button>
<button
onClick={() =>
handleAddResult(
JSON.stringify({
success: false,
message: "User cancelled file movement",
})
)
}
className="px-4 py-2 bg-[--background-modifier-border] text-[--text-normal] rounded-md hover:bg-[--background-modifier-border-hover]"
>
Cancel
</button>
</div>
)}
</div>
);
}
103 changes: 68 additions & 35 deletions plugin/views/ai-chat/tool-handlers/onboard-handler.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState } from "react";
import { TFolder } from "obsidian";
import { TFile, TFolder } from "obsidian";
import { ToolHandlerProps } from "./types";
import { addFileContext } from "../use-context-items";

interface FolderStructure {
name: string;
Expand All @@ -23,59 +24,91 @@ export function OnboardHandler({
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isValidated, setIsValidated] = useState(false);

const analyzeFolderStructure = (
folder: TFolder,
const getFilesFromPath = (path: string): TFile[] => {
if (path === "/") {
return app.vault.getMarkdownFiles();
}

const folder = app.vault.getAbstractFileByPath(path);
if (!folder || !(folder instanceof TFolder)) {
return [];
}

const files: TFile[] = [];
folder.children.forEach(child => {
if (child instanceof TFile) {
files.push(child);
}
});
return files;
};

const analyzeFolderStructure = async (
path: string,
depth = 0,
maxDepth = 3
): FolderStructure => {
if (depth >= maxDepth) return null;
maxDepth = 3,
shouldAddToContext = false
) => {
const files = getFilesFromPath(path);
const structure = {
path,
files: await Promise.all(files.map(async file => {
const fileData = {
name: file.name,
path: file.path,
content: await app.vault.read(file),
type: "file" as const,
depth: depth + 1,
};

// Add to context if requested
if (shouldAddToContext) {
addFileContext({
path: file.path,
title: file.basename,
content: fileData.content,
});
}

const structure: FolderStructure = {
name: folder.name,
type: "folder",
children: [],
return fileData;
})),
subfolders: [],
depth,
};

// Get immediate children
folder.children.forEach((child) => {
if (child instanceof TFolder) {
const subStructure = analyzeFolderStructure(child, depth + 1, maxDepth);
if (subStructure) {
structure.children.push(subStructure);
if (depth < maxDepth && path !== "/") {
const folder = app.vault.getAbstractFileByPath(path) as TFolder;
if (folder && folder instanceof TFolder) {
for (const child of folder.children) {
if (child instanceof TFolder) {
const subStructure = await analyzeFolderStructure(
child.path,
depth + 1,
maxDepth,
shouldAddToContext
);
structure.subfolders.push(subStructure);
}
}
} else {
structure.children.push({
name: child.name,
type: "file",
depth: depth + 1,
});
}
});
}

return structure;
};

const handleAnalyze = async () => {
setIsAnalyzing(true);
try {
const rootFolder = app.vault.getRoot();
const structure = analyzeFolderStructure(rootFolder);
const { path = "/", maxDepth = 3, addToContext = false } = toolInvocation.args;
const structure = await analyzeFolderStructure(path, 0, maxDepth, addToContext);

// Filter out system folders and files
const filteredStructure = {
...structure,
children: structure.children.filter(
(child) => !child.name.startsWith(".")
),
};

setIsValidated(true);
handleAddResult(
JSON.stringify({
success: true,
structure: filteredStructure,
message: "Vault structure analyzed successfully",
structure,
analyzedPath: path,
message: `Vault structure analyzed successfully for path: ${path}`,
})
);
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ScreenpipeHandler } from "./screenpipe-handler";
import { SettingsUpdateHandler } from "./settings-update-handler";
import { AppendContentHandler } from "./append-content-handler";
import { OnboardHandler } from "./onboard-handler";
import { MoveFilesHandler } from "./move-files-handler";

interface ToolInvocationHandlerProps {
toolInvocation: any;
Expand Down Expand Up @@ -37,6 +38,7 @@ function ToolInvocationHandler({
generateSettings: "Settings Update",
appendContentToFile: "Append Content",
analyzeVaultStructure: "Vault Analysis",
moveFiles: "Moving Files",
};
return toolTitles[toolName] || "Tool Invocation";
};
Expand Down Expand Up @@ -95,6 +97,13 @@ function ToolInvocationHandler({
app={app}
/>
),
moveFiles: () => (
<MoveFilesHandler
toolInvocation={toolInvocation}
handleAddResult={handleAddResult}
app={app}
/>
),
};

return handlers[toolInvocation.toolName]?.() || null;
Expand Down
16 changes: 16 additions & 0 deletions web/app/api/(newai)/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,9 @@ export async function POST(req: NextRequest) {
analyzeVaultStructure: {
description: "Analyze vault structure to suggest organization improvements",
parameters: z.object({
path: z.string().describe("Path to analyze. Use '/' for all files or specific folder path"),
maxDepth: z.number().optional().describe("Maximum depth to analyze"),
addToContext: z.boolean().optional().describe("Whether to add analyzed files to context")
}),
},
getScreenpipeDailySummary: {
Expand All @@ -105,6 +107,20 @@ export async function POST(req: NextRequest) {
endTime: z.string().optional().describe("End time in ISO format"),
}),
},
moveFiles: {
description: "Move files to their designated folders",
parameters: z.object({
moves: z.array(z.object({
sourcePath: z.string().describe("Source path (e.g., '/' for root, or specific folder path)"),
destinationPath: z.string().describe("Destination folder path"),
pattern: z.object({
namePattern: z.string().optional().describe("File name pattern to match (e.g., 'untitled-*')"),
extension: z.string().optional().describe("File extension to match")
}).optional()
})),
message: z.string().describe("Confirmation message to show user")
}),
},
},
onFinish: async ({ usage }) => {
console.log("Token usage:", usage);
Expand Down

0 comments on commit 38ecd7f

Please sign in to comment.