Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 0 additions & 14 deletions .eslintignore

This file was deleted.

1 change: 1 addition & 0 deletions COMMIT_MESSAGE.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

126 changes: 124 additions & 2 deletions apps/server/src/providers/google/google-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
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
Expand All @@ -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<string, string> = {
modificationDate: "modifiedTime",
creationDate: "createdTime",
webContentLink: "webContentLink",
webViewLink: "webViewLink",
};

const mappedFields = returnedValues.map(field => fieldMap[field] || field);
return mappedFields.join(", ");
}

function genericTypeToProviderMimeType(type: string): string {
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/providers/interface/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ export interface Provider {
*/
deleteFile(fileId: string): Promise<boolean>;

/**
* 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<File | null>;
// exportFile(fileId: string, mimeType: string): Promise<Blob | null>;
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/routes/drives/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
164 changes: 164 additions & 0 deletions apps/server/src/routes/files/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getFilesSchema,
updateFileSchema,
uploadFileSchema,
searchFilesSchema,
MAX_FILE_SIZE,
ALLOWED_MIME_TYPES,
} from "@/validators";
Expand All @@ -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";
Expand Down Expand Up @@ -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<ApiResponse>({ 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<ApiResponse>({ 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(
Expand Down
Loading