diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 1fc326ea..00000000 --- a/.eslintignore +++ /dev/null @@ -1,14 +0,0 @@ -# for oxlint -# https://oxc.rs/docs/guide/usage/linter/generated-cli.html#ignore-files - -# I know it is deprecated...just ignore the warning -# https://eslint.org/docs/latest/use/configure/ignore-deprecated - -**/server/lib/google-drive/** -**/server/lib/one-drive/** -**/node_modules -**/.next -**/dist -**/build -**/coverage -**/out \ No newline at end of file diff --git a/COMMIT_MESSAGE.txt b/COMMIT_MESSAGE.txt new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/COMMIT_MESSAGE.txt @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/server/src/providers/google/google-drive.ts b/apps/server/src/providers/google/google-drive.ts index bd4aa933..901300c4 100644 --- a/apps/server/src/providers/google/google-drive.ts +++ b/apps/server/src/providers/google/google-drive.ts @@ -211,6 +211,120 @@ export class GoogleDriveProvider implements Provider { usageInTrash: driveAbout.data.storageQuota?.usageInDriveTrash, } as DriveInfo; } + + /** + * Search files in Google Drive + * @param query The search query + * @param pageSize The number of files to return per page + * @param returnedValues The values the file object will contain + * @param pageToken The next page token for pagination + * @returns An array of files matching the search query and the next page token + */ + async searchFiles( + query: string, + pageSize: number, + returnedValues: string[], + pageToken?: string + ): Promise<{ files: File[]; nextPageToken?: string }> { + // Build Google Drive search query + const searchQuery = this.buildSearchQuery(query); + + const response = await this.drive.files.list({ + fields: `files(${returnedValuesToFields(returnedValues)}), nextPageToken`, + pageSize, + pageToken, + q: searchQuery, + }); + + if (!response.data.files) { + return { files: [] }; + } + + const files: File[] = response.data.files.map(file => convertGoogleDriveFileToProviderFile(file)); + + return { + files, + nextPageToken: response.data.nextPageToken || undefined, + }; + } + + /** + * Build Google Drive search query from user input + * Supports various search patterns like type:pdf, tag:important, etc. + */ + private buildSearchQuery(userQuery: string): string { + const queryParts: string[] = []; + const tokens = userQuery.trim().split(/\s+/); + const nameTokens: string[] = []; + + for (const token of tokens) { + if (token.startsWith("type:")) { + // Handle file type search (e.g., type:pdf) + const fileType = token.substring(5).toLowerCase(); + const mimeType = this.getGoogleDriveMimeType(fileType); + if (mimeType) { + queryParts.push(`mimeType='${mimeType}'`); + } + } else if (token.startsWith("tag:")) { + // Handle tag search - we'll need to filter by tags in the route handler + // For now, we'll treat it as a name search + nameTokens.push(token.substring(4)); + } else { + // Regular name search + nameTokens.push(token); + } + } + + // Add name search if we have name tokens + if (nameTokens.length > 0) { + const nameQuery = nameTokens.join(" "); + queryParts.push(`name contains '${this.escapeSearchQuery(nameQuery)}'`); + } + + // Always exclude trashed files + queryParts.push("trashed=false"); + + return queryParts.join(" and "); + } + + /** + * Get Google Drive MIME type for common file types + */ + private getGoogleDriveMimeType(fileType: string): string | null { + const mimeTypeMap: Record = { + pdf: "application/pdf", + doc: "application/vnd.google-apps.document", + docs: "application/vnd.google-apps.document", // Google Docs + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.google-apps.spreadsheet", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.google-apps.presentation", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + txt: "text/plain", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + zip: "application/zip", // ZIP archives + rar: "application/vnd.rar", // RAR archives + "7z": "application/x-7z-compressed", // 7-Zip archives + tar: "application/x-tar", // TAR archives + gz: "application/gzip", // GZIP archives + folder: "application/vnd.google-apps.folder", + document: "application/vnd.google-apps.document", + spreadsheet: "application/vnd.google-apps.spreadsheet", + presentation: "application/vnd.google-apps.presentation", + }; + + return mimeTypeMap[fileType] || null; + } + + /** + * Escape special characters in search query for Google Drive + */ + private escapeSearchQuery(query: string): string { + return query.replace(/'/g, "\\'").replace(/\\/g, "\\\\"); + } } // Helper functions @@ -231,8 +345,16 @@ function convertGoogleDriveFileToProviderFile(file: drive_v3.Schema$File): File } function returnedValuesToFields(returnedValues: string[]) { - // Handle undefined behavior - return returnedValues.join(", "); + // Map frontend field names to Google Drive API field names + const fieldMap: Record = { + modificationDate: "modifiedTime", + creationDate: "createdTime", + webContentLink: "webContentLink", + webViewLink: "webViewLink", + }; + + const mappedFields = returnedValues.map(field => fieldMap[field] || field); + return mappedFields.join(", "); } function genericTypeToProviderMimeType(type: string): string { diff --git a/apps/server/src/providers/interface/provider.ts b/apps/server/src/providers/interface/provider.ts index 924ea10d..95939d05 100644 --- a/apps/server/src/providers/interface/provider.ts +++ b/apps/server/src/providers/interface/provider.ts @@ -48,6 +48,21 @@ export interface Provider { */ deleteFile(fileId: string): Promise; + /** + * Search files in the user's drive + * @param query The search query + * @param pageSize The number of files to return per page + * @param returnedValues The values the file object will contain + * @param pageToken The next page token for pagination + * @returns An array of files matching the search query and the next page token + */ + searchFiles( + query: string, + pageSize: number, + returnedValues: string[], + pageToken?: string + ): Promise<{ files: File[]; nextPageToken?: string }>; + // Future methods: // copyFile(fileId: string, newName?: string, parent?: string): Promise; // exportFile(fileId: string, mimeType: string): Promise; diff --git a/apps/server/src/routes/drives/index.ts b/apps/server/src/routes/drives/index.ts index f17f1977..ddb43a42 100644 --- a/apps/server/src/routes/drives/index.ts +++ b/apps/server/src/routes/drives/index.ts @@ -1,6 +1,8 @@ import { getDriveManagerForUser } from "@/providers"; import { getAccount } from "@/lib/utils/accounts"; import type { ApiResponse } from "@/routes/types"; +import { securityMiddleware } from "@/middleware"; +import type { Session } from "@nimbus/auth/auth"; import type { Context } from "hono"; import { Hono } from "hono"; diff --git a/apps/server/src/routes/files/index.ts b/apps/server/src/routes/files/index.ts index c4fba8cb..b28a624e 100644 --- a/apps/server/src/routes/files/index.ts +++ b/apps/server/src/routes/files/index.ts @@ -5,6 +5,7 @@ import { getFilesSchema, updateFileSchema, uploadFileSchema, + searchFilesSchema, MAX_FILE_SIZE, ALLOWED_MIME_TYPES, } from "@/validators"; @@ -13,6 +14,7 @@ import { fileGetRateLimiter, fileUpdateRateLimiter, fileUploadRateLimiter, + fileSearchRateLimiter, } from "@nimbus/cache/rate-limiters"; import type { ApiResponse, UploadedFile } from "@/routes/types"; import type { File } from "@/providers/interface/types"; @@ -70,6 +72,168 @@ filesRouter.get( } ); +// Search files route +filesRouter.get( + "/search", + securityMiddleware({ + rateLimiting: { + enabled: true, + rateLimiter: fileSearchRateLimiter, + }, + securityHeaders: true, + }), + async (c: Context) => { + const user: Session["user"] = c.get("user"); + + const { data, error } = searchFilesSchema.safeParse({ + query: c.req.query("query"), + pageSize: c.req.query("pageSize"), + returnedValues: c.req.queries("returnedValues[]"), + pageToken: c.req.query("pageToken") ?? undefined, + }); + + if (error) { + return c.json({ success: false, message: error.errors[0]?.message }, 400); + } + + // Parse query to separate tag searches from file name searches + const originalQuery = data.query; + const queryTokens = originalQuery.split(/\s+/); + + // Extract tag-related tokens + const tagTokens = queryTokens.filter(token => token.startsWith("tag:") || token.startsWith("+tag:")); + const regularTagTokens = queryTokens.filter(token => token.startsWith("tag:")); + const andTagTokens = queryTokens.filter(token => token.startsWith("+tag:")); + + // Extract non-tag tokens for Google Drive search + const fileSearchTokens = queryTokens.filter(token => !token.startsWith("tag:") && !token.startsWith("+tag:")); + const fileSearchQuery = fileSearchTokens.join(" ").trim(); + + let filesWithTags: File[] = []; + let nextPageToken: string | undefined = undefined; + + // Determine search strategy + const isPureTagSearch = tagTokens.length > 0 && fileSearchTokens.length === 0; + const hasMixedSearch = tagTokens.length > 0 && fileSearchTokens.length > 0; + const hasFileSearchOnly = tagTokens.length === 0 && fileSearchTokens.length > 0; + + if (isPureTagSearch) { + // Pure tag search: get files by tag IDs from local DB, then fetch from Google Drive + const searchTagNames = regularTagTokens.map(token => token.substring(4).toLowerCase()); + const andTagNames = andTagTokens.map(token => token.substring(5).toLowerCase()); + + let taggedFileIds: string[] = []; + + if (andTagNames.length > 0) { + // AND operation: get files that have ALL specified tags + const allUserTags = await tagService.getUserTags(user.id); + const flatTags = allUserTags.flatMap(tag => [tag, ...(tag.children || [])]); + + const matchingTagIds = flatTags + .filter(tag => andTagNames.some(searchTag => tag.name.toLowerCase().includes(searchTag))) + .map(tag => tag.id); + + if (matchingTagIds.length === andTagNames.length) { + // Get file IDs that have ALL the required tags + taggedFileIds = await tagService.getFileIdsByAllTags(matchingTagIds, user.id); + } + } else { + // OR operation: get files that have ANY of the specified tags + const allUserTags = await tagService.getUserTags(user.id); + const flatTags = allUserTags.flatMap(tag => [tag, ...(tag.children || [])]); + + const matchingTagIds = flatTags + .filter(tag => searchTagNames.some(searchTag => tag.name.toLowerCase().includes(searchTag))) + .map(tag => tag.id); + + if (matchingTagIds.length > 0) { + taggedFileIds = await tagService.getFileIdsByAnyTags(matchingTagIds, user.id); + } + } + + // Fetch specific files from Google Drive + if (taggedFileIds.length > 0) { + const drive = await getDriveManagerForUser(user, c.req.raw.headers); + const filePromises = taggedFileIds.slice(0, data.pageSize).map(async fileId => { + try { + return await drive.getFileById(fileId, data.returnedValues); + } catch (error) { + console.warn(`Failed to fetch file ${fileId}:`, error); + return null; + } + }); + + const fetchedFiles = (await Promise.all(filePromises)).filter(file => file !== null); + + // Add tags to fetched files + filesWithTags = await Promise.all( + fetchedFiles.map(async file => { + if (!file.id) return { ...file, tags: [] }; + const tags = await tagService.getFileTags(file.id, user.id); + return { ...file, tags }; + }) + ); + } + // Pure tag search doesn't support pagination from Google Drive + nextPageToken = undefined; + } else { + // File search (with or without tag filtering) + // Use existing search logic - send only non-tag terms to Google Drive + const searchQuery = hasFileSearchOnly ? originalQuery : fileSearchQuery; + + if (!searchQuery.trim()) { + // If no file search terms, return empty results + return c.json({ files: [], nextPageToken: undefined }); + } + + const drive = await getDriveManagerForUser(user, c.req.raw.headers); + const res = await drive.searchFiles(searchQuery, data.pageSize, data.returnedValues, data.pageToken); + + if (!res.files) { + return c.json({ success: false, message: "No files found" }, 404); + } + + // Store pagination token + nextPageToken = res.nextPageToken; + + // Add tags to files (existing logic preserved) + filesWithTags = await Promise.all( + res.files.map(async file => { + if (!file.id) return { ...file, tags: [] }; + const tags = await tagService.getFileTags(file.id, user.id); + return { ...file, tags }; + }) + ); + + // Apply tag filtering for mixed searches (existing logic preserved) + if (hasMixedSearch) { + let filteredFiles = filesWithTags; + const searchTagNames = regularTagTokens.map(token => token.substring(4).toLowerCase()); + const andTagNames = andTagTokens.map(token => token.substring(5).toLowerCase()); + + if (andTagNames.length > 0) { + // AND operation: file must have ALL specified tags + filteredFiles = filesWithTags.filter(file => + andTagNames.every(searchTag => file.tags?.some(tag => tag.name.toLowerCase().includes(searchTag))) + ); + } else { + // OR operation: file must have ANY of the specified tags (default behavior) + filteredFiles = filesWithTags.filter(file => + file.tags?.some(tag => searchTagNames.some(searchTag => tag.name.toLowerCase().includes(searchTag))) + ); + } + + filesWithTags = filteredFiles; + } + } + + return c.json({ + files: filesWithTags, + nextPageToken, + }); + } +); + // Get a specific file from // TODO: Grab fileId from url path, not the params filesRouter.get( diff --git a/apps/server/src/routes/tags/tag-service.ts b/apps/server/src/routes/tags/tag-service.ts index 98f6469a..086e3fdc 100644 --- a/apps/server/src/routes/tags/tag-service.ts +++ b/apps/server/src/routes/tags/tag-service.ts @@ -301,4 +301,53 @@ export class TagService { async deleteFileTagsByFileId(fileId: string, userId: string): Promise { await db.delete(fileTag).where(and(eq(fileTag.fileId, fileId), eq(fileTag.userId, userId))); } + + // Get file IDs that have ANY of the specified tags (OR operation) + async getFileIdsByAnyTags(tagIds: string[], userId: string): Promise { + if (tagIds.length === 0) return []; + + const fileTagAssociations = await db + .select({ + fileId: fileTag.fileId, + }) + .from(fileTag) + .where(and(inArray(fileTag.tagId, tagIds), eq(fileTag.userId, userId))); + + // Remove duplicates and return unique file IDs + const uniqueFileIds = [...new Set(fileTagAssociations.map(assoc => assoc.fileId))]; + return uniqueFileIds; + } + + // Get file IDs that have ALL of the specified tags (AND operation) + async getFileIdsByAllTags(tagIds: string[], userId: string): Promise { + if (tagIds.length === 0) return []; + + // For each tag, get the file IDs + const fileIdsByTag = await Promise.all( + tagIds.map(async tagId => { + const fileTagAssociations = await db + .select({ + fileId: fileTag.fileId, + }) + .from(fileTag) + .where(and(eq(fileTag.tagId, tagId), eq(fileTag.userId, userId))); + + return fileTagAssociations.map(assoc => assoc.fileId); + }) + ); + + // Find intersection - files that appear in ALL tag lists + if (fileIdsByTag.length === 0) return []; + + let intersection = fileIdsByTag[0]; + if (!intersection) return []; + + for (let i = 1; i < fileIdsByTag.length; i++) { + const currentTagFiles = fileIdsByTag[i]; + if (!currentTagFiles) return []; + intersection = intersection.filter(fileId => currentTagFiles.includes(fileId)); + } + + return intersection; + } } diff --git a/apps/server/src/validators/index.ts b/apps/server/src/validators/index.ts index 948ca466..9188275f 100644 --- a/apps/server/src/validators/index.ts +++ b/apps/server/src/validators/index.ts @@ -48,6 +48,19 @@ export const getFileByIdSchema = z.object({ returnedValues: z.string().array(), }); +export const searchFilesSchema = z.object({ + query: z + .string() + .min(1, "Search query cannot be empty") + .max(200, "Search query cannot be longer than 200 characters"), + pageSize: z.coerce.number().int().min(1).max(100).default(30), + returnedValues: z + .string() + .array() + .default(["id", "name", "mimeType", "size", "modificationDate", "webContentLink", "webViewLink"]), + pageToken: z.string().optional(), +}); + export const deleteFileSchema = z.object({ fileId: fileIdSchema, }); diff --git a/apps/web/eslint.config.ts b/apps/web/eslint.config.ts index eaa7ab4a..41d92010 100644 --- a/apps/web/eslint.config.ts +++ b/apps/web/eslint.config.ts @@ -1,13 +1,17 @@ -import { buildEslintConfig } from "@nimbus/eslint"; import { FlatCompat } from "@eslint/eslintrc"; const compat = new FlatCompat({ baseDirectory: import.meta.dirname, }); -const baseConfig = buildEslintConfig(); -const nextConfig = compat.extends("next/core-web-vitals", "next/typescript"); - -const eslintConfig = [...baseConfig, ...nextConfig]; +// Use only Next.js config to avoid plugin conflicts +// The base config is already applied at the root level +const eslintConfig = [ + ...compat.extends("next/core-web-vitals", "next/typescript"), + { + // Add any web-specific ignores here if needed + ignores: ["node_modules/**", ".next/**", "dist/**", "build/**", "coverage/**", "out/**"], + }, +]; export default eslintConfig; diff --git a/apps/web/src/components/dashboard/file-browser/file-tabs.tsx b/apps/web/src/components/dashboard/file-browser/file-tabs.tsx index 6a056fee..1ae1c9a6 100644 --- a/apps/web/src/components/dashboard/file-browser/file-tabs.tsx +++ b/apps/web/src/components/dashboard/file-browser/file-tabs.tsx @@ -1,20 +1,46 @@ import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useRouter, useSearchParams } from "next/navigation"; export function FileTabs({ type }: { type: string | null }) { + const router = useRouter(); + const searchParams = useSearchParams(); + + const handleTabChange = (value: string) => { + const params = new URLSearchParams(searchParams); + if (value === "all") { + params.delete("type"); + } else { + params.set("type", value); + } + router.push(`?${params.toString()}`); + }; + return ( - - - -

All

+ + + + All - -

Folders

+ + Folders - -

Documents

+ + Documents - -

Media

+ + Media
diff --git a/apps/web/src/components/dashboard/file-browser/index.tsx b/apps/web/src/components/dashboard/file-browser/index.tsx index df3a5126..4c5467af 100644 --- a/apps/web/src/components/dashboard/file-browser/index.tsx +++ b/apps/web/src/components/dashboard/file-browser/index.tsx @@ -30,10 +30,53 @@ export function FileBrowser() { // Update local state when server data changes useEffect(() => { if (data) { - setLocalFiles(data); + let filteredFiles = [...data]; + + if (type) { + filteredFiles = data.filter((file: _File) => { + const mimeType = file.mimeType?.toLowerCase() ?? ""; + const fileName = file.name?.toLowerCase() ?? ""; + + switch (type) { + case "folder": + return mimeType === "application/vnd.google-apps.folder" || mimeType === "folder"; + case "document": + return ( + // Google Docs + mimeType.includes("application/vnd.google-apps.document") || + mimeType.includes("application/vnd.google-apps.spreadsheet") || + mimeType.includes("application/vnd.google-apps.presentation") || + // Microsoft Office + mimeType.includes("officedocument") || + mimeType.includes("msword") || + // PDFs + mimeType.includes("pdf") || + // Text files + mimeType.includes("text/") || + // Common document extensions + /\.(doc|docx|xls|xlsx|ppt|pptx|pdf|txt|rtf|odt|ods|odp)$/i.test(fileName) + ); + case "media": + return ( + // Images + mimeType.includes("image/") || + // Videos + mimeType.includes("video/") || + // Audio + mimeType.includes("audio/") || + // Common media extensions + /\.(jpg|jpeg|png|gif|bmp|webp|mp4|webm|mov|mp3|wav|ogg)$/i.test(fileName) + ); + default: + return true; + } + }); + } + + setLocalFiles(filteredFiles); setOriginalFiles(data); } - }, [data]); + }, [data, type]); // Optimistic delete handler const handleOptimisticDelete = (fileId: string) => { diff --git a/apps/web/src/components/search/search-dialog.tsx b/apps/web/src/components/search/search-dialog.tsx index 50628b70..fcdf4137 100644 --- a/apps/web/src/components/search/search-dialog.tsx +++ b/apps/web/src/components/search/search-dialog.tsx @@ -1,201 +1,216 @@ "use client"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; -import { FileText, Filter, Folder, Search, Tag } from "lucide-react"; +import { FileText, Filter, Folder, Search, Tag as TagIcon, Loader2 } from "lucide-react"; +import { FileTags } from "@/components/dashboard/file-browser/file-tags"; +import { useSearchFiles } from "@/hooks/useFileOperations"; import { Card, CardContent } from "@/components/ui/card"; +import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; +import type { _File, Tag } from "@/lib/types"; +import { useState, useEffect } from "react"; import type { KeyboardEvent } from "react"; -import { useState } from "react"; - -interface SearchResult { - id: string; - name: string; - type: string; - size: string; - modified: string; - keywords: string[]; - tags: { id: string; name: string; color: string }[]; -} +import { useTags } from "@/hooks/useTags"; +import { toast } from "sonner"; interface SearchDialogProps { open: boolean; onOpenChange: (open: boolean) => void; } -// Demo data for search results -const DEMO_FILES: SearchResult[] = [ - { - id: "1", - name: "Q4_Financial_Report.xlsx", - type: "spreadsheet", - size: "2.4 MB", - modified: "May 10, 2024", - keywords: ["financial", "report", "q4", "spreadsheet", "billing", "revenue"], - tags: [ - { id: "tag1", name: "financial", color: "bg-green-500" }, - { id: "tag2", name: "important", color: "bg-red-500" }, - ], - }, - { - id: "2", - name: "Invoice_Template.xlsx", - type: "spreadsheet", - size: "1.2 MB", - modified: "May 8, 2024", - keywords: ["invoice", "template", "billing", "spreadsheet", "accounting"], - tags: [ - { id: "tag3", name: "billing", color: "bg-blue-500" }, - { id: "tag4", name: "template", color: "bg-purple-500" }, - ], - }, - { - id: "3", - name: "Budget_Planning_2024.xlsx", - type: "spreadsheet", - size: "3.1 MB", - modified: "May 5, 2024", - keywords: ["budget", "planning", "2024", "spreadsheet", "financial"], - tags: [ - { id: "tag1", name: "financial", color: "bg-green-500" }, - { id: "tag5", name: "planning", color: "bg-yellow-500" }, - ], - }, - { - id: "4", - name: "Meeting_Notes.docx", - type: "document", - size: "0.8 MB", - modified: "May 3, 2024", - keywords: ["meeting", "notes", "document", "discussion"], - tags: [{ id: "tag6", name: "meetings", color: "bg-indigo-500" }], - }, - { - id: "5", - name: "Project_Proposal.pdf", - type: "document", - size: "4.2 MB", - modified: "May 1, 2024", - keywords: ["project", "proposal", "document", "plan"], - tags: [ - { id: "tag7", name: "projects", color: "bg-pink-500" }, - { id: "tag2", name: "important", color: "bg-red-500" }, - ], - }, - { - id: "6", - name: "Client_Presentation.pptx", - type: "presentation", - size: "15.3 MB", - modified: "April 28, 2024", - keywords: ["presentation", "client", "slides", "proposal"], - tags: [ - { id: "tag8", name: "presentations", color: "bg-orange-500" }, - { id: "tag9", name: "client", color: "bg-cyan-500" }, - ], - }, - { - id: "7", - name: "Team_Photos", - type: "folder", - size: "156 MB", - modified: "April 25, 2024", - keywords: ["photos", "team", "images", "pictures"], - tags: [{ id: "tag10", name: "photos", color: "bg-emerald-500" }], - }, - { - id: "8", - name: "Expense_Report_March.xlsx", - type: "spreadsheet", - size: "890 KB", - modified: "April 20, 2024", - keywords: ["expense", "report", "march", "billing", "accounting"], - tags: [ - { id: "tag3", name: "billing", color: "bg-blue-500" }, - { id: "tag11", name: "expenses", color: "bg-amber-500" }, - ], - }, -]; +/** + * SearchDialog Component - Advanced File Search with Tag Support + * + * TAG SEARCH FUNCTIONALITY: + * + * 1. Basic Tag Search: + * - "tag:important" - finds files tagged with "important" + * - "tag:work" - finds files tagged with "work" + * - Supports partial matching: "tag:imp" will match "important" + * + * 2. Multiple Tags (OR operation - default): + * - "tag:important tag:work" - finds files with EITHER "important" OR "work" tags + * + * 3. Multiple Tags (AND operation): + * - "+tag:important +tag:work" - finds files with BOTH "important" AND "work" tags + * + * 4. Combined Search: + * - "tag:important financial report" - finds files tagged "important" AND containing "financial report" in name + * - "type:pdf tag:important" - finds PDFs that are tagged "important" + * + * 5. File Type Search: + * - "type:pdf" - finds PDF files + * - "type:spreadsheet" - finds spreadsheet files + * - "type:presentation" - finds presentation files + * + * The search interface provides: + * - Dynamic tag suggestions based on most popular tags + * - Visual tag indicators with colors and file counts + * - Quick access buttons for common searches + * - Real-time search with debouncing + */ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) { const [query, setQuery] = useState(""); - const [searchResults, setSearchResults] = useState([]); + const [searchResults, setSearchResults] = useState<_File[]>([]); const [selectedFile, setSelectedFile] = useState(null); const [extractedKeywords, setExtractedKeywords] = useState([]); const [hasSearched, setHasSearched] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + + const queryClient = useQueryClient(); + + // Use only the Files endpoint for Google Drive search + const { + data: searchData, + isLoading, + error, + refetch, + } = useSearchFiles( + searchQuery, + 30, + ["id", "name", "mimeType", "size", "modificationDate", "webContentLink", "webViewLink"], + undefined + ); + + const { tags } = useTags(); + + // Helper function to flatten hierarchical tag structure for suggestions + const flattenTags = (tags: Tag[]): Tag[] => { + const flattened: Tag[] = []; + const flattenRecursive = (tagList: Tag[]) => { + tagList.forEach(tag => { + flattened.push(tag); + if (tag.children && tag.children.length > 0) { + flattenRecursive(tag.children); + } + }); + }; + flattenRecursive(tags || []); + return flattened; + }; + + // Get popular tags (tags with most files) + const getPopularTags = () => { + const flatTags = flattenTags(tags || []); + return flatTags + .filter(tag => (tag._count || 0) > 0) + .sort((a, b) => (b._count || 0) - (a._count || 0)) + .slice(0, 5); + }; + + useEffect(() => { + if (open) { + setQuery(""); + setSearchResults([]); + setSelectedFile(null); + setExtractedKeywords([]); + setHasSearched(false); + setSearchQuery(""); + } + }, [open]); - const handleSearch = (searchQuery?: string) => { + useEffect(() => { + if (searchData?.files) { + setSearchResults(searchData.files); + } + }, [searchData]); + + useEffect(() => { + if (error) { + toast.error("Failed to search files. Please try again."); + } + }, [error]); + + const handleSearch = async (searchQuery?: string) => { const queryToSearch = searchQuery || query; if (!queryToSearch.trim()) { setSearchResults([]); setExtractedKeywords([]); setHasSearched(false); + setSearchQuery(""); return; } - // Simple keyword extraction - const keywords = queryToSearch - .toLowerCase() - .split(/\s+/) - .filter(word => word.length > 2); - setExtractedKeywords(keywords); - - // Filter files based on keywords - const results = DEMO_FILES.filter(file => { - const searchText = - `${file.name} ${file.keywords.join(" ")} ${file.tags.map(t => t.name).join(" ")}`.toLowerCase(); - return keywords.some(keyword => searchText.includes(keyword.toLowerCase())); - }); - - setSearchResults(results); + setSearchQuery(queryToSearch); + setExtractedKeywords( + queryToSearch + .toLowerCase() + .split(/\s+/) + .filter(word => word.length > 2) + ); setHasSearched(true); }; const handleKeyPress = (e: KeyboardEvent) => { if (e.key === "Enter") { - handleSearch(); + void handleSearch(); } }; const handleSuggestionClick = (suggestionQuery: string) => { setQuery(suggestionQuery); - handleSearch(suggestionQuery); + void handleSearch(suggestionQuery); }; const toggleFileSelection = (fileId: string) => { setSelectedFile(prev => (prev === fileId ? null : fileId)); }; - const addTagToSelected = (tagName: string) => { - if (!selectedFile) return; + const handleRefetch = () => { + void refetch(); + void queryClient.invalidateQueries({ queryKey: ["tags"] }); + }; - const newTag = { - id: `tag_${Date.now()}`, - name: tagName, - color: "bg-blue-500", - }; + const getFileIcon = (mimeType: string) => { + if (mimeType.includes("folder") || mimeType === "application/vnd.google-apps.folder") { + return ; + } + if (mimeType.includes("spreadsheet") || mimeType.includes("sheet")) { + return ; + } + if (mimeType.includes("presentation") || mimeType.includes("slides")) { + return ; + } + return ; + }; - setSearchResults(prev => - prev.map(file => (file.id === selectedFile ? { ...file, tags: [...file.tags, newTag] } : file)) - ); + const formatFileSize = (size: string | null) => { + if (!size) return "Unknown size"; + const bytes = parseInt(size); + if (isNaN(bytes)) return size; - setSelectedFile(null); // Clear selection after tagging + const units = ["B", "KB", "MB", "GB"]; + let unitIndex = 0; + let fileSize = bytes; + + while (fileSize >= 1024 && unitIndex < units.length - 1) { + fileSize /= 1024; + unitIndex++; + } + + return `${fileSize.toFixed(1)} ${units[unitIndex]}`; }; - const getFileIcon = (type: string) => { - switch (type) { - case "folder": - return ; - case "spreadsheet": - return ; - case "presentation": - return ; - default: - return ; + const formatDate = (dateString: string | null) => { + if (!dateString) return "Unknown date"; + try { + return new Date(dateString).toLocaleDateString(); + } catch { + return "Invalid date"; } }; + const getFileType = (mimeType: string) => { + if (mimeType.includes("folder")) return "folder"; + if (mimeType.includes("spreadsheet") || mimeType.includes("sheet")) return "spreadsheet"; + if (mimeType.includes("presentation") || mimeType.includes("slides")) return "presentation"; + if (mimeType.includes("document") || mimeType.includes("text")) return "document"; + return "file"; + }; + // Reset when dialog closes const handleOpenChange = (newOpen: boolean) => { if (!newOpen) { @@ -204,10 +219,13 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) { setSelectedFile(null); setExtractedKeywords([]); setHasSearched(false); + setSearchQuery(""); } onOpenChange(newOpen); }; + const popularTags = getPopularTags(); + return ( @@ -216,10 +234,10 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
- Advanced Search + Google Drive Search - Search through your files and organize them with tags + Search files by name, type, or tags. Examples: "type:pdf", "tag:important"
@@ -231,7 +249,7 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
setQuery(e.target.value)} onKeyDown={handleKeyPress} @@ -239,32 +257,55 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) { autoFocus />
- {/* Quick Search Examples */}
- - - - {selectedFile && ( - )} + + {/* Dynamic tag suggestions */} + {popularTags.map(tag => ( + + ))}
{/* Keywords */} @@ -287,21 +328,16 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) { {/* Results */}
- {searchResults.length > 0 ? ( + {isLoading ? ( +
+ +
+ ) : searchResults.length > 0 ? (

Found {searchResults.length} result{searchResults.length !== 1 ? "s" : ""}

- {selectedFile && ( -
- 1 selected - -
- )}
@@ -317,28 +353,20 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
- {getFileIcon(file.type)} + {getFileIcon(file.mimeType)}

{file.name}

- {file.modified} + {formatDate(file.modificationDate)} - {file.size} + {formatFileSize(file.size)} - {file.type} + {getFileType(file.mimeType)}
- {file.tags.map(tag => ( - - {tag.name} - - ))} + {tags && }
@@ -365,32 +393,6 @@ export function SearchDialog({ open, onOpenChange }: SearchDialogProps) {
)}
- - {/* Quick Actions */} - {searchResults.length > 0 && ( -
-
- - Quick actions: -
-
- - - -
-
- )}
); diff --git a/apps/web/src/hooks/useFileOperations.ts b/apps/web/src/hooks/useFileOperations.ts index 8562ebdd..036b2728 100644 --- a/apps/web/src/hooks/useFileOperations.ts +++ b/apps/web/src/hooks/useFileOperations.ts @@ -43,6 +43,22 @@ export function useGetFile(fileId: string, returnedValues: string[]) { }); } +export function useSearchFiles(query: string, pageSize: number, returnedValues: string[], nextPageToken?: string) { + return useQuery({ + queryKey: ["files", "search", query, nextPageToken, pageSize], + queryFn: async () => { + const response = await axios.get(`${API_BASE}/search`, { + params: { query, pageSize, returnedValues, pageToken: nextPageToken }, + ...defaultAxiosConfig, + }); + return response.data; + }, + staleTime: 2 * 60 * 1000, // 2 minutes (shorter for search results) + retry: 2, + enabled: !!query.trim(), // Only run query if there's a search term + }); +} + export function useDeleteFile() { const queryClient = useQueryClient(); return useMutation({ diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 6d35da52..c58c076d 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -69,6 +69,18 @@ export interface UploadFileParams { returnedValues: string[]; } +export interface SearchFilesParams { + query: string; + pageSize?: number; + returnedValues?: string[]; + nextPageToken?: string; +} + +export interface SearchResult { + files: _File[]; + nextPageToken?: string; +} + // Auth types export interface AuthState { diff --git a/eslint.config.ts b/eslint.config.ts index 58234983..e152cc93 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -1,5 +1,21 @@ import { buildEslintConfig } from "@nimbus/eslint"; -const eslintConfig = [...buildEslintConfig(), { ignores: ["apps/web/**"] }]; +const eslintConfig = [ + ...buildEslintConfig(), + { + ignores: [ + "apps/web/**", + // Migrated from .eslintignore + "**/server/lib/google-drive/**", + "**/server/lib/one-drive/**", + "**/node_modules/**", + "**/.next/**", + "**/dist/**", + "**/build/**", + "**/coverage/**", + "**/out/**", + ], + }, +]; export default eslintConfig; diff --git a/packages/cache/src/rate-limiters.ts b/packages/cache/src/rate-limiters.ts index 1cee4404..9878500a 100644 --- a/packages/cache/src/rate-limiters.ts +++ b/packages/cache/src/rate-limiters.ts @@ -48,8 +48,18 @@ export const fileUploadRateLimiter = new RateLimiterRedis({ storeClient: redisClient, keyPrefix: "rl:files:upload", points: isProduction ? 50 : 500, - duration: 60 * 5, - blockDuration: 60 * 4, + duration: 60 * 6, + blockDuration: 60 * 6, + inMemoryBlockOnConsumed: isProduction ? 150 : 1500, + inMemoryBlockDuration: 60, +}); + +export const fileSearchRateLimiter = new RateLimiterRedis({ + storeClient: redisClient, + keyPrefix: "rl:files:search", + points: isProduction ? 50 : 500, + duration: 60 * 6, + blockDuration: 60 * 6, inMemoryBlockOnConsumed: isProduction ? 150 : 1500, inMemoryBlockDuration: 60, });