diff --git a/.cursorrules b/.cursorrules index 9d2c0d43..43081d5a 100644 --- a/.cursorrules +++ b/.cursorrules @@ -147,6 +147,7 @@ export async function POST(req: Request) { import { ToolInvocation } from 'ai'; import { Message, useChat } from 'ai/react'; +import { pipeline } from 'stream' export default function Chat() { const { @@ -228,4 +229,27 @@ Additional Notes: • Regularly update dependencies and libraries to their latest versions for improved performance and security. • Test the chatbot thoroughly to handle edge cases and unexpected user inputs. -This revised prompt organizes the information more clearly, making it easier to understand and follow. It highlights key project guidelines, structures, and code examples, providing a comprehensive overview for anyone involved in the development process. \ No newline at end of file +This revised prompt organizes the information more clearly, making it easier to understand and follow. It highlights key project guidelines, structures, and code examples, providing a comprehensive overview for anyone involved in the development process. + + +## pipeline + +We have an inbox that processes files. + +The process happens in these steps: +- preprocess : trim content | check if we support file type | check if we have a license +- extract (ai): extract text from file, we have a function in the plugin/index.ts for that +- classify (ai): classify the file, we have a function in the plugin/index.ts for that +- tag (ai): tag the file, we have a function in the plugin/index.ts for that +- format (ai): format the file, we have a function in the plugin/index.ts for that +- move: move the file to the correct folder, we have a function in the plugin/index.ts for that + + +each step should be logged in the record manager, and we should record the start and end of each step. + +all the ai steps are two folds one api call to get the llm recommendations +and one call to apply the recommendation. add tag after tagging , move file after folder recommendation, rename file after naming + +when you classify apply a tag to the document there's append tag funciton on plugin +only format if 1. there's a classification 2. there's no tag with the classification presetn + diff --git a/plugin/constants.ts b/plugin/constants.ts index 38e6b4a9..a907034d 100644 --- a/plugin/constants.ts +++ b/plugin/constants.ts @@ -1,6 +1,13 @@ import { Notice } from "obsidian"; -export const VALID_IMAGE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "svg", "webp"]; +export const VALID_IMAGE_EXTENSIONS = [ + "png", + "jpg", + "jpeg", + "gif", + "svg", + "webp", +]; export const VALID_AUDIO_EXTENSIONS = [ "mp3", @@ -15,6 +22,7 @@ export const VALID_AUDIO_EXTENSIONS = [ export const VALID_MEDIA_EXTENSIONS = [ ...VALID_IMAGE_EXTENSIONS, ...VALID_AUDIO_EXTENSIONS, + "pdf", ]; export const VALID_TEXT_EXTENSIONS = ["md", "txt"]; @@ -36,4 +44,4 @@ export const isValidExtension = (extension: string): boolean => { new Notice("Sorry, FileOrganizer does not support this file type."); } return isSupported; -}; \ No newline at end of file +}; diff --git a/plugin/inbox/index.ts b/plugin/inbox/index.ts index d33b1ea6..d7b10c3e 100644 --- a/plugin/inbox/index.ts +++ b/plugin/inbox/index.ts @@ -1,10 +1,14 @@ import { TFile, moment, TFolder } from "obsidian"; import FileOrganizer from "../index"; -import { validateFile } from "../utils"; import { Queue } from "./services/queue"; -import { RecordManager } from "./services/record-manager"; -import { FileRecord, QueueStatus } from "./types"; -import { logMessage } from "../someUtils"; +import { + FileRecord, + RecordManager, + Action, + FileStatus, +} from "./services/record-manager"; +import { QueueStatus } from "./types"; +import { cleanPath, logMessage } from "../someUtils"; import { IdService } from "./services/id-service"; import { logger } from "../services/logger"; import { @@ -12,11 +16,12 @@ import { getTokenCount, cleanup, } from "../utils/token-counter"; +import { isValidExtension, VALID_MEDIA_EXTENSIONS } from "../constants"; +import { ensureFolderExists } from "../fileUtils"; // Move constants to the top level and ensure they're used consistently -const CONTENT_PREVIEW_LENGTH = 500; -const MAX_CONCURRENT_TASKS = 20; -const MAX_CONCURRENT_MEDIA_TASKS = 5; +const MAX_CONCURRENT_TASKS = 5; +const MAX_CONCURRENT_MEDIA_TASKS = 2; const TOKEN_LIMIT = 50000; // 50k tokens limit for formatting export interface FolderSuggestion { @@ -48,15 +53,14 @@ interface EventRecord { } interface ProcessingContext { - file: TFile; + inboxFile: TFile; + containerFile?: TFile; + attachmentFile?: TFile; hash: string; - record: FileRecord; content?: string; newPath?: string; newName?: string; tags?: string[]; - containerFile?: TFile; - attachmentFile?: TFile; plugin: FileOrganizer; recordManager: RecordManager; idService: IdService; @@ -67,7 +71,6 @@ interface ProcessingContext { confidence: number; reasoning: string; }; - contentPreview?: string; suggestedTags?: Array<{ score: number; isNew: boolean; @@ -79,7 +82,7 @@ interface ProcessingContext { export class Inbox { protected static instance: Inbox; private plugin: FileOrganizer; - private activeMediaTasks: number = 0; + private activeMediaTasks = 0; private mediaQueue: Array = []; private queue: Queue; @@ -139,24 +142,14 @@ export class Inbox { // First enqueue regular files for (const file of regularFiles) { const hash = this.idService.generateFileHash(file); - const record = this.recordManager.createOrUpdateFileRecord(file); - this.recordManager.updateFileStatus( - record, - "queued", - "File enqueued for processing" - ); + this.recordManager.startTracking(hash); this.queue.add(file, { metadata: { hash } }); } // Then enqueue media files for (const file of mediaFiles) { const hash = this.idService.generateFileHash(file); - const record = this.recordManager.createOrUpdateFileRecord(file); - this.recordManager.updateFileStatus( - record, - "queued", - "Media file enqueued for processing" - ); + this.recordManager.startTracking(hash); this.queue.add(file, { metadata: { hash } }); } @@ -186,7 +179,7 @@ export class Inbox { this.activeMediaTasks++; } - await this.processInboxFile(file); + await this.processInboxFile(file, metadata?.hash); if (isMediaFile) { this.activeMediaTasks--; @@ -222,11 +215,13 @@ export class Inbox { } public getFileStatus(filePath: string): FileRecord | undefined { - return this.recordManager.getRecordByPath(filePath); + // return this.recordManager.getRecordByPath(filePath); + return undefined; } public getFileEvents(fileId: string): EventRecord[] { - return this.recordManager.getFileEvents(fileId); + // return this.recordManager.getFileEvents(fileId); + return []; } public getAllFiles(): FileRecord[] { @@ -244,233 +239,278 @@ export class Inbox { }; } - // Refactored method using a pipeline-style processing - private async processInboxFile(file: TFile): Promise { - const hash = this.idService.generateFileHash(file); - const record = this.recordManager.getRecordByHash(hash); - if (!record) return; + public getAnalytics(): { + byStatus: Record; + totalFiles: number; + mediaStats: { + active: number; + queued: number; + }; + queueStats: QueueStatus; + } { + const records = this.getAllFiles(); + const byStatus = records.reduce((acc, record) => { + acc[record.status] = (acc[record.status] || 0) + 1; + return acc; + }, {} as Record); + + return { + byStatus, + totalFiles: records.length, + mediaStats: this.getMediaProcessingStats(), + queueStats: this.getQueueStats(), + }; + } + + // Refactored method using parallel processing where possible + private async processInboxFile( + inboxFile: TFile, + hash?: string + ): Promise { + this.recordManager.setStatus(hash, "processing"); + console.log("Processing inbox file", inboxFile); const context: ProcessingContext = { - file, + inboxFile, + // from now on we will only work with the container file hash, - record, plugin: this.plugin, recordManager: this.recordManager, idService: this.idService, queue: this.queue, }; + console.log("Processing inbox file", context.inboxFile.path); try { - await startProcessing(context) - .then(validateFileStep) - .then(extractTextStep) - .then(preprocessContentStep) - .then(classifyDocument) - .then(suggestTags) - .then(suggestFolder) - .then(suggestTitle) - .then(processFileStep) - .then(formatContentStep) - .then(completeProcessing); + await startProcessing(context); + await hasValidFileStep(context); + await getContainerFileStep(context); + await moveAttachmentFile(context); + await getContentStep(context); + await cleanupStep(context); + await recommendClassificationStep(context); + await recommendFolderStep(context); + await recommendNameStep(context); + await formatContentStep(context); + await appendAttachmentStep(context); + await recommendTagsStep(context); + await completeProcessing(context); } catch (error) { - logger.error("Error processing inbox file:", error); await handleError(error, context); + logger.error("Error processing inbox file:", error); } } } - -// Pipeline processing steps - -async function startProcessing( +async function moveAttachmentFile( context: ProcessingContext ): Promise { - context.recordManager.updateFileStatus( - context.record, - "processing", - "Started processing file" + context.recordManager.addAction(context.hash, Action.MOVING_ATTACHEMENT); + if (VALID_MEDIA_EXTENSIONS.includes(context.inboxFile.extension)) { + context.attachmentFile = context.inboxFile; + await moveFile( + context, + context.inboxFile, + context.plugin.settings.attachmentsPath + ); + } + context.recordManager.addAction( + context.hash, + Action.MOVING_ATTACHEMENT, + true ); return context; } -async function validateFileStep( +async function getContainerFileStep( context: ProcessingContext ): Promise { - if (!validateFile(context.file)) { - context.queue.bypass(context.hash); - await moveFileToBypassedFolder(context); - context.recordManager.recordProcessingBypassed( - context.record, - "File validation failed" + if (VALID_MEDIA_EXTENSIONS.includes(context.inboxFile.extension)) { + const containerFile = await context.plugin.app.vault.create( + context.inboxFile.basename + ".md", + `` ); - throw new Error("File validation failed"); + context.containerFile = containerFile; + } else { + context.containerFile = context.inboxFile; } + context.recordManager.setFile(context.hash, context.containerFile); + // return the inboxFile if it is not a media file return context; } -async function extractTextStep( +async function hasValidFileStep( context: ProcessingContext ): Promise { - try { - if (context.plugin.shouldCreateMarkdownContainer(context.file)) { - // Handle image/media files - const content = await context.plugin.generateImageAnnotation(context.file); - logger.info("Extracted text from image/media", content); - context.content = content; - } else { - // Handle regular text files - const text = await context.plugin.getTextFromFile(context.file); - logger.info("Extracted text from file", text); - context.content = text; - } - return context; - } catch (error) { - logger.error("Error in extractTextStep:", error); - context.recordManager.recordError(context.record, error); - throw error; + // check if file is supported if not bypass + if (!isValidExtension(context.inboxFile.extension)) { + await handleBypass(context, "Unsupported file type"); + throw new Error("Unsupported file type"); } + return context; } -async function preprocessContentStep( +async function recommendNameStep( context: ProcessingContext ): Promise { - if (!context.content) return context; - - // Strip front matter before checking content length - const contentWithoutFrontMatter = context.content - .replace(/^---\n[\s\S]*?\n---\n/, "") - .trim(); - - // Check for minimum content length (ignoring front matter) - if (contentWithoutFrontMatter.length < 5) { - logger.info( - "Content too short (excluding front matter), bypassing processing" - ); - context.queue.bypass(context.hash); - await moveFileToBypassedFolder(context); - context.recordManager.recordProcessingBypassed( - context.record, - "Content too short (less than 5 characters, excluding front matter)" - ); - throw new Error("Content too short (excluding front matter)"); - } - - // Check if content appears to be binary - const isBinary = /[\u0000-\u0008\u000E-\u001F]/.test( - context.content.slice(0, 1000) + const newName = await context.plugin.recommendName( + context.content, + context.inboxFile.basename ); - if (isBinary) { - logger.info("Binary file detected, bypassing content preprocessing"); - context.queue.bypass(context.hash); - await moveFileToBypassedFolder(context); - context.recordManager.recordProcessingBypassed( - context.record, - "Binary file detected" - ); - throw new Error("Binary file detected"); - } - - // Create a preview of the content for initial analysis - context.contentPreview = context.content.slice(0, CONTENT_PREVIEW_LENGTH); - - // Add ellipsis if content was truncated - if (context.content.length > CONTENT_PREVIEW_LENGTH) { - context.contentPreview += "..."; + context.newName = newName[0]?.title; + // if new name is the same as the old name then don't rename + if (context.newName === context.inboxFile.basename) { + return context; } - - logger.info("Content preview generated", { - previewLength: context.contentPreview.length, - fullLength: context.content.length, - }); - + context.recordManager.setNewName(context.hash, context.newName); + context.recordManager.addAction(context.hash, Action.RENAME); + await renameFile(context, context.containerFile, context.newName); + context.recordManager.addAction(context.hash, Action.RENAME, true); return context; } -async function classifyDocument( +async function recommendFolderStep( context: ProcessingContext ): Promise { - if (!context.plugin.settings.enableDocumentClassification) return context; - const templateNames = await context.plugin.getTemplateNames(); - const result = await context.plugin.classifyContentV2( + const newPath = await context.plugin.recommendFolders( context.content, - templateNames + context.inboxFile.basename ); - context.classification = { - documentType: result, - confidence: 100, - reasoning: "N/A", - }; - if (context.classification) { - context.recordManager.recordClassification( - context.record, - context.classification - ); - // Start formatting content early if we have classification - if (context.classification.confidence >= 80) { - await formatContentStep(context); - } - } + context.newPath = newPath[0]?.folder; + console.log("new path", context.newPath, context.containerFile); + context.recordManager.addAction(context.hash, Action.MOVING); + await moveFile(context, context.containerFile, context.newPath); + context.recordManager.addAction(context.hash, Action.MOVING, true); + console.log("moved file to", context.containerFile); return context; } -async function suggestFolder( +async function recommendClassificationStep( context: ProcessingContext ): Promise { - const recommendedFolders = await context.plugin.recommendFolders( - context.content, - context.file.name - ); - - context.newPath = recommendedFolders[0]?.folder; - - if (context.newPath) { - // Create folder structure immediately - await ensureFolder(context, context.newPath); + if (context.plugin.settings.enableDocumentClassification) { + const templateNames = await context.plugin.getTemplateNames(); - // Move file to new folder immediately, keeping original filename for now - const newFilePath = `${context.newPath}/${context.file.name}`; - await moveFile(context, context.file, newFilePath); - - // Update record - context.recordManager.recordMove( - context.record, - context.file.path, - context.newPath + context.recordManager.addAction(context.hash, Action.CLASSIFY); + const result = await context.plugin.classifyContentV2( + context.content, + templateNames ); + context.plugin.appendTag(context.containerFile, result); + context.classification = { + documentType: result, + confidence: 100, + reasoning: "N/A", + }; + context.recordManager.addAction(context.hash, Action.CLASSIFY, true); + context.recordManager.setClassification(context.hash, result); } + return context; +} +// Pipeline processing steps + +async function startProcessing( + context: ProcessingContext +): Promise { + console.log("startProcessing", context); return context; } -async function suggestTitle( +async function getContentStep( context: ProcessingContext ): Promise { - const result = await context.plugin.recommendName( - context.content, - context.file.name - ); - context.newName = result[0]?.title; + const fileToRead = context.inboxFile; + context.recordManager.addAction(context.hash, Action.EXTRACT); + const content = await context.plugin.getTextFromFile(fileToRead); + context.content = content; + console.log("content", content); + console.log("containerFile", context.containerFile); + await context.plugin.app.vault.modify(context.containerFile, content); + context.recordManager.addAction(context.hash, Action.EXTRACT, true); + return context; +} - if (context.newName) { - context.recordManager.recordRename( - context.record, - context.file.basename, - context.newName - ); +async function cleanupStep( + context: ProcessingContext +): Promise { + try { + // Early return if no content + if (!context.content) { + await handleBypass(context, "No content available"); + } + + // Strip front matter and trim + const contentWithoutFrontMatter = context.content + .replace(/^---\n[\s\S]*?\n---\n/, "") + .trim(); + + // Bypass if content is too short + if (contentWithoutFrontMatter.length < 5) { + await handleBypass(context, "Content too short (less than 5 characters)"); + } + + // Set the cleaned content back + context.content = contentWithoutFrontMatter; + return context; + } catch (error) { + logger.error("Error in preprocessContentStep:", error); + throw error; } +} - return context; +// New helper function to handle bypassing +async function handleBypass( + context: ProcessingContext, + reason: string +): Promise { + try { + // First mark as bypassed in the record manager + + // Then move the file + const bypassedFolderPath = context.plugin.settings.bypassedFilePath; + await moveFile(context, context.inboxFile, bypassedFolderPath); + + context.queue.bypass(context.hash); + context.recordManager.setStatus(context.hash, "bypassed"); + throw new Error("Bypassed due to " + reason); + } catch (error) { + logger.error("Error in handleBypass:", error); + throw error; + } } async function formatContentStep( context: ProcessingContext ): Promise { - logger.info("Formatting content step", context.classification); + if (!context.classification) { + logger.info("Skipping formatting: no classification available"); + return context; + } + // Early return if no classification + if (!context.classification.documentType) { + logger.info("Skipping formatting: no classification available"); + return context; + } - if (!context.classification || context.classification.confidence < 80) { + // Early return if classification confidence is too low + if (context.classification.confidence < 80) { + logger.info("Skipping formatting: classification confidence too low", { + confidence: context.classification.confidence, + }); return context; } + + // Early return if no content + if (!context.content) { + logger.info("Skipping formatting: no content available"); + return context; + } + + logger.info("Formatting content step", context.classification); + context.recordManager.addAction(context.hash, Action.FORMATTING); + // get token amount from token counter await initializeTokenCounter(); const tokenAmount = getTokenCount(context.content); @@ -484,183 +524,65 @@ async function formatContentStep( } try { - // Make sure we have content to format - if (!context.content) { - throw new Error("No content available for formatting"); - } - - // Initialize token counter if needed - await initializeTokenCounter(); - - // Check token count - const tokenCount = getTokenCount(context.content); - if (tokenCount > TOKEN_LIMIT) { - logger.info( - `Skipping formatting: content too large (${tokenCount} tokens)` - ); - context.recordManager.recordProcessingBypassed( - context.record, - `Content too large for formatting (${tokenCount} tokens)` - ); - return context; - } - const instructions = await context.plugin.getTemplateInstructions( context.classification.documentType ); - // Call the new v2 endpoint - const response = await fetch( - `${context.plugin.getServerUrl()}/api/format/v2`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${context.plugin.settings.API_KEY}`, - }, - body: JSON.stringify({ - content: context.content, - formattingInstruction: instructions, - }), - } - ); - - if (!response.ok) { - throw new Error(`Format failed: ${response.statusText}`); + if (!instructions) { + logger.info("Skipping formatting: no instructions available"); + return context; } - const result = await response.json(); - context.formattedContent = result.content; + const formattedContent = await context.plugin.formatContentV2( + context.content, + instructions + ); + context.formattedContent = formattedContent; + context.plugin.app.vault.modify(context.containerFile, formattedContent); + context.recordManager.addAction(context.hash, Action.FORMATTING, true); return context; } catch (error) { logger.error("Error in formatContentStep:", error); - context.recordManager.recordError(context.record, error); throw error; } } - -async function processFileStep( +async function recommendTagsStep( context: ProcessingContext ): Promise { - try { - const isMediaFile = context.plugin.shouldCreateMarkdownContainer(context.file); - const finalPath = `${context.newPath}/${context.newName}.md`; - - if (isMediaFile) { - // Add error handling for media files - try { - // Ensure we have string content - const contentToUse = ( - context.formattedContent || - context.content || - "No content extracted" - ).toString(); - - // Add front matter with metadata - const containerContent = [ - "---", - `original-file: ${context.file.name}`, - `processed-date: ${moment().format('YYYY-MM-DD HH:mm:ss')}`, - `file-type: ${context.file.extension}`, - "---", - "", - contentToUse, - "", // Empty line for separation - ].join("\n"); - - // Create container file with better error handling - context.containerFile = await createFile( - context, - finalPath, - containerContent - ).catch(error => { - logger.error("Failed to create container file:", error); - throw error; - }); - - // Create attachments folder with better path handling - const attachmentFolderPath = `${context.newPath}/attachments`; - await ensureFolder(context, attachmentFolderPath); - - // Move the original media file with better error handling - const attachmentPath = `${attachmentFolderPath}/${context.file.name}`; - await moveFile(context, context.file, attachmentPath); - - context.attachmentFile = context.plugin.app.vault.getAbstractFileByPath( - attachmentPath - ) as TFile; - - if (!context.attachmentFile) { - throw new Error("Failed to locate moved attachment file"); - } - - // Update container with attachment reference - const finalContent = [ - containerContent, - "## Original File", - `![[${context.attachmentFile.path}]]`, - ].join("\n"); - - await context.plugin.app.vault.modify( - context.containerFile, - finalContent - ); - - } catch (mediaError) { - logger.error("Media processing error:", mediaError); - context.recordManager.recordError(context.record, mediaError); - throw mediaError; - } - } else { - // Regular file processing - if (context.formattedContent) { - // Ensure we're writing a string - const contentToWrite = context.formattedContent.toString(); - await context.plugin.app.vault.modify(context.file, contentToWrite); - } - - if (context.newName !== context.file.basename) { - await moveFile(context, context.file, finalPath); - } - } - - // Record metadata updates - if (context.classification) { - context.recordManager.recordClassification( - context.record, - context.classification - ); - } - - if (context.tags?.length) { - context.recordManager.recordTags(context.record, context.tags); - } - - if (context.newName !== context.file.basename) { - context.recordManager.recordRename( - context.record, - context.file.basename, - context.newName! - ); - } - - return context; - } catch (error) { - logger.error("Error in processFileStep:", error); - context.recordManager.recordError(context.record, error); - throw error; + context.recordManager.addAction(context.hash, Action.TAGGING); + const existingTags = await context.plugin.getAllVaultTags(); + const tags = await context.plugin.recommendTags( + context.content, + context.containerFile.path, + existingTags + ); + context.tags = tags?.map(t => t.tag); + // for each tag, append it to the file + for (const tag of context.tags) { + await context.plugin.appendTag(context.containerFile, tag); } + context.recordManager.addAction(context.hash, Action.TAGGING, true); + context.recordManager.setTags(context.hash, context.tags); + return context; +} +async function appendAttachmentStep( + context: ProcessingContext +): Promise { + if (context.attachmentFile) { + context.plugin.app.vault.append( + context.containerFile, + `\n\n![[${context.attachmentFile.name}]]` + ); + } + return context; } async function completeProcessing( context: ProcessingContext ): Promise { - context.recordManager.recordProcessingComplete(context.record, { - newPath: context.newPath!, - newName: context.newName!, - tags: context.tags, - }); + context.recordManager.addAction(context.hash, Action.COMPLETED, true); + context.recordManager.setStatus(context.hash, "completed"); return context; } @@ -670,91 +592,62 @@ async function handleError( error: any, context: ProcessingContext ): Promise { - context.recordManager.recordError(context.record, error); + console.log("handleError", error); + context.recordManager.setStatus(context.hash, "error"); + await moveFileToErrorFolder(context); - context.recordManager.updateFileStatus( - context.record, - "error", - `Processing error: ${error.message}`, - { - errorDetails: { - message: error.message, - stack: error.stack, - fileName: context.file.name, - newLocation: context.plugin.settings.errorFilePath, - }, - } - ); } // Helper functions for file operations -async function moveFileToBypassedFolder( - context: ProcessingContext -): Promise { - const bypassedFolderPath = context.plugin.settings.bypassedFilePath; - const newPath = `${bypassedFolderPath}/${context.file.name}`; - await moveFile(context, context.file, newPath); -} - async function moveFileToErrorFolder( context: ProcessingContext ): Promise { const errorFolderPath = context.plugin.settings.errorFilePath; - const newPath = `${errorFolderPath}/${context.file.name}`; - await moveFile(context, context.file, newPath); + await moveFile(context, context.inboxFile, errorFolderPath); } -async function moveFile( +async function renameFile( context: ProcessingContext, file: TFile, - newPath: string + newName: string ): Promise { - try { - await ensureFolder(context, newPath); - - const exists = await context.plugin.app.vault.adapter.exists(newPath); - if (exists) { - const timestamp = moment().format("YYYY-MM-DD-HHmmss"); - const parts = newPath.split("."); - const ext = parts.pop(); - newPath = `${parts.join(".")}-${timestamp}.${ext}`; - } - - await context.plugin.app.vault.rename(file, newPath); - } catch (error) { - logger.error(`Failed to move file ${file.path} to ${newPath}:`, error); - throw new Error(`Failed to move file: ${error.message}`); + const parentPath = file.parent.path; + const extension = file.extension; + let uniqueName = newName; + let destinationPath = `${parentPath}/${uniqueName}.${extension}`; + + // Check if the destination file exists + while (context.plugin.app.vault.getAbstractFileByPath(destinationPath)) { + // Generate a new unique name by appending a suffix + uniqueName = `${newName}-${moment().format("YYYYMMDD-HHmmss")}`; + destinationPath = `${parentPath}/${uniqueName}.${extension}`; } -} -async function createFile( - context: ProcessingContext, - path: string, - content: string -): Promise { - try { - await ensureFolder(context, path); - return await context.plugin.app.vault.create(path, content); - } catch (error) { - logger.error(`Failed to create file at ${path}:`, error); - throw new Error(`Failed to create file: ${error.message}`); - } + await context.plugin.app.fileManager.renameFile(file, destinationPath); } -async function ensureFolder( +async function moveFile( context: ProcessingContext, - path: string + file: TFile, + newFolderPath: string ): Promise { - const folderPath = path.split("/").slice(0, -1).join("/"); - try { - await context.plugin.app.vault.createFolder(folderPath); - } catch (error) { - if (!error.message.includes("already exists")) { - logger.error("Error creating folder:", error); - throw error; - } + await ensureFolderExists(context.plugin.app, newFolderPath); + + const fileName = file.name; + let destinationPath = `${cleanPath(newFolderPath)}/${fileName}`; + + // Check if the destination file exists + while (context.plugin.app.vault.getAbstractFileByPath(destinationPath)) { + // Generate a new unique name by appending a suffix + const baseName = fileName.replace(`.${file.extension}`, ""); + const uniqueName = `${baseName}-${moment().format("YYYYMMDD-HHmmss")}.${ + file.extension + }`; + destinationPath = `${cleanPath(newFolderPath)}/${uniqueName}`; } + + await context.plugin.app.fileManager.renameFile(file, destinationPath); } // Helper functions for initialization and usage @@ -770,40 +663,3 @@ export function enqueueFiles(files: TFile[]): void { export function getInboxStatus(): QueueStatus { return Inbox.getInstance().getQueueStats(); } - -async function suggestTags( - context: ProcessingContext -): Promise { - const existingTags = await context.plugin.getAllVaultTags(); - const response = await fetch(`${context.plugin.getServerUrl()}/api/tags/v2`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${context.plugin.settings.API_KEY}`, - }, - body: JSON.stringify({ - content: context.content, - fileName: context.file.name, - existingTags, - count: 5, - }), - }); - - if (!response.ok) - throw new Error(`Tag suggestion failed: ${response.statusText}`); - const result = await response.json(); - - // Filter tags by confidence threshold (e.g., 70) - context.suggestedTags = result.tags.filter(t => t.score >= 70); - context.tags = context.suggestedTags.map(t => t.tag); - - // Apply tags immediately if we have suggestions - if (context.tags?.length) { - for (const tag of context.tags) { - await context.plugin.appendTag(context.file, tag); - } - context.recordManager.recordTags(context.record, context.tags); - } - - return context; -} diff --git a/plugin/inbox/services/record-manager.ts b/plugin/inbox/services/record-manager.ts index 010cf5d2..d44c8c9e 100644 --- a/plugin/inbox/services/record-manager.ts +++ b/plugin/inbox/services/record-manager.ts @@ -1,30 +1,64 @@ import { TFile } from "obsidian"; -import { FileRecord, FileStatus, FileMetadata, EventRecord, Classification } from "../types"; import { IdService } from "./id-service"; -import { ErrorService, ErrorSeverity } from "./error-service"; -import { isMediaFile } from "../utils/file"; import moment from "moment"; -interface ActionLog { - action: 'renamed' | 'moved' | 'classified' | 'tagged' | 'error' | 'processing' | 'queued' | 'analyzing'; +export enum Action { + CLEANUP = "cleaning up file", + CLEANUP_DONE = "file cleaned up", + RENAME = "renaming file", + RENAME_DONE = "file renamed", + EXTRACT = "extracting text", + EXTRACT_DONE = "text extracted", + MOVING_ATTACHEMENT = "moving attachments", + MOVING_ATTACHEMENT_DONE = "attachments moved", + CLASSIFY = "classifying", + CLASSIFY_DONE = "classified", + TAGGING = "recommending tags", + TAGGING_DONE = "tags recommended", + APPLYING_TAGS = "applying tags", + APPLYING_TAGS_DONE = "tags applied", + RECOMMEND_NAME = "recommending name", + RECOMMEND_NAME_DONE = "name recommended", + APPLYING_NAME = "applying name", + APPLYING_NAME_DONE = "name applied", + FORMATTING = "formatting", + FORMATTING_DONE = "formatted", + MOVING = "moving", + MOVING_DONE = "moved", + COMPLETED = "completed", +} + +export interface LogEntry { timestamp: string; - details: { - from?: string; - to?: string; - tags?: string[]; - classification?: Classification; - error?: string; - destinationFolder?: string; - wasFormatted?: boolean; - step?: string; - progress?: string; + completed?: boolean; + error?: { + message: string; + stack?: string; }; } +export type FileStatus = + | "queued" + | "processing" + | "completed" + | "error" + | "bypassed"; + +export interface FileRecord { + id: string; + tags: string[]; + classification?: string; + formatted: boolean; + newPath?: string; + newName?: string; + logs: Record; + status: FileStatus; + file: TFile | null; +} + export class RecordManager { private static instance: RecordManager; - private fileRecords: Map = new Map(); - private eventRecords: Map = new Map(); + private records: Map = new Map(); private idService: IdService; private constructor() { @@ -38,276 +72,164 @@ export class RecordManager { return RecordManager.instance; } - public createOrUpdateFileRecord( - file: TFile, - updates?: Partial - ): FileRecord { - try { - const hash = this.idService.generateFileHash(file); - const existingRecord = this.getRecordByHash(hash); - - if (existingRecord) { - if (updates) { - return this.updateRecord(hash, updates); - } - return existingRecord; - } - - const now = moment().format(); - const metadata: FileMetadata = { - size: file.stat.size, - extension: file.extension, - createdTime: file.stat.ctime, - modifiedTime: file.stat.mtime, - isMediaFile: isMediaFile(file), - }; - - const newRecord: FileRecord = { + public startTracking(hash: string): string { + if (!this.records.has(hash)) { + this.records.set(hash, { id: hash, - filePath: file.path, - fileName: file.basename, - previousName: file.basename, - status: "queued" as FileStatus, - createdAt: now, - updatedAt: now, - metadata, - errors: [], - ...updates, - }; - - this.fileRecords.set(hash, newRecord); - this.addEvent(hash, "File record initialized", { metadata }); - - return newRecord; - } catch (error) { - ErrorService.getInstance().handleError({ - message: "Failed to create/update file record", - severity: ErrorSeverity.HIGH, - error: error as Error, - context: { filePath: file.path }, + file: null, + tags: [], + formatted: false, + logs: {} as Record, + status: "queued", }); - throw error; } + return hash; } - private updateRecord(hash: string, updates: Partial): FileRecord { - const record = this.getRecordByHash(hash); - if (!record) { - throw new Error(`Record not found for hash: ${hash}`); + public setFile(hash: string, file: TFile): void { + const record = this.records.get(hash); + if (record) { + record.file = file; } - - const updatedRecord = { - ...record, - ...updates, - updatedAt: moment().format(), - }; - - this.fileRecords.set(hash, updatedRecord); - return updatedRecord; } - public updateFileStatus( - record: FileRecord, - status: FileStatus, - message?: string, - metadata?: Record - ): void { - const hash = record.id; - - // Update the record with new status - this.updateRecord(hash, { - status, - updatedAt: moment().format(), - }); - - // Add event with status change - const statusMessage = message || `File status changed to ${status}`; - this.addEvent(hash, statusMessage, { - status, - previousStatus: record.status, - ...metadata, - }); - } - - public recordProcessingStart(record: FileRecord): void { - this.updateFileStatus(record, "processing", "Started processing file"); - } - - public recordProcessingComplete( - record: FileRecord, - metadata?: { - newPath?: string; - newName?: string; - tags?: string[]; - } - ): void { - const updates: Partial = { - status: "completed", - updatedAt: moment().format(), - }; - - if (metadata) { - // Update file paths - if (metadata.newPath) updates.newPath = metadata.newPath; - if (metadata.newName) updates.newName = metadata.newName; - - // Update tags - if (metadata.tags) updates.tags = metadata.tags; - - // Update processing information + public setStatus(hash: string, status: FileStatus): void { + const record = this.records.get(hash); + if (record) { + record.status = status; } - - this.updateRecord(record.id, updates); - this.addEvent(record.id, "File processing completed", metadata); } - public recordProcessingBypassed(record: FileRecord, reason?: string): void { - this.updateFileStatus( - record, - "bypassed", - reason || "File processing bypassed" - ); + public addAction(hash: string, step: Action, completed = false): void { + const record = this.records.get(hash); + if (record) { + // For completed actions, find and update the corresponding in-progress action + if (completed) { + const baseAction = this.getBaseAction(step); + if (baseAction && record.logs[baseAction]) { + record.logs[baseAction].completed = true; + return; + } + } + + // For new actions, add them as in-progress + record.logs[step] = { + timestamp: moment().format("YYYY-MM-DD HH:mm:ss"), + completed, + }; + } } - public recordError(record: FileRecord, error: Error): void { - this.logAction(record, 'error', { error: error.message }); + private getBaseAction(completedStep: Action): Action | undefined { + const reverseMap: Partial> = { + [Action.CLEANUP_DONE]: Action.CLEANUP, + [Action.RENAME_DONE]: Action.RENAME, + [Action.EXTRACT_DONE]: Action.EXTRACT, + [Action.MOVING_ATTACHEMENT_DONE]: Action.MOVING_ATTACHEMENT, + [Action.CLASSIFY_DONE]: Action.CLASSIFY, + [Action.TAGGING_DONE]: Action.TAGGING, + [Action.APPLYING_TAGS_DONE]: Action.APPLYING_TAGS, + [Action.RECOMMEND_NAME_DONE]: Action.RECOMMEND_NAME, + [Action.APPLYING_NAME_DONE]: Action.APPLYING_NAME, + [Action.FORMATTING_DONE]: Action.FORMATTING, + [Action.MOVING_DONE]: Action.MOVING, + }; + return reverseMap[completedStep]; } - public updateDestination( - record: FileRecord, - newName: string, - newPath: string - ): void { - const hash = record.id; - this.updateRecord(hash, { newName, newPath }); - this.addEvent(hash, `Updated destination: ${newPath}/${newName}`); + // Record update methods + public addTag(hash: string, tag: string): void { + const record = this.records.get(hash); + if (record && !record.tags.includes(tag)) { + record.tags.push(tag); + } } - public addTags(record: FileRecord, tags: string[]): void { - const hash = record.id; - this.updateRecord(hash, { tags }); - this.addEvent(hash, `Added tags: ${tags.join(", ")}`); + public setTags(hash: string, tags: string[]): void { + const record = this.records.get(hash); + if (record) { + record.tags = tags; + } } - public getRecordByHash(hash: string): FileRecord | undefined { - return this.fileRecords.get(hash); + public setClassification(hash: string, classification: string): void { + const record = this.records.get(hash); + if (record) { + record.classification = classification; + } } - public getRecordByPath(path: string): FileRecord | undefined { - return Array.from(this.fileRecords.values()).find( - record => record.filePath === path - ); + public setFormatted(hash: string, formatted: boolean): void { + const record = this.records.get(hash); + if (record) { + record.formatted = formatted; + } } - public getAllRecords(): FileRecord[] { - return Array.from(this.fileRecords.values()); + public setNewPath(hash: string, newPath: string): void { + const record = this.records.get(hash); + if (record) { + record.newPath = newPath; + } } - public getFileEvents(fileId: string): EventRecord[] { - return this.eventRecords.get(fileId) || []; + public setNewName(hash: string, newName: string): void { + const record = this.records.get(hash); + if (record) { + record.newName = newName; + } } - private addEvent( - hash: string, - message: string, - metadata?: Record - ): void { - const event: EventRecord = { - id: this.idService.generateEventId(hash, Date.now()), - fileRecordId: hash, - timestamp: moment().format(), - message, - metadata, - }; - - const events = this.eventRecords.get(hash) || []; - events.push(event); - this.eventRecords.set(hash, events); + // Logging methods + // Query methods + public getRecord(hash: string): FileRecord | undefined { + return this.records.get(hash); } - public logAction(record: FileRecord, action: ActionLog['action'], details: ActionLog['details']): void { - const actionLog: ActionLog = { - action, - timestamp: moment().format(), - details - }; + public hasErrors(hash: string, step?: Action): boolean { + const record = this.records.get(hash); + if (!record) return false; - this.updateRecord(record.id, { - actions: [...(record.actions || []), actionLog] - }); - } - - public recordRename(record: FileRecord, oldName: string, newName: string): void { - this.logAction(record, 'renamed', { from: oldName, to: newName }); - } + if (step) { + return !!record.logs[step]?.error; + } - public recordMove(record: FileRecord, oldPath: string, newPath: string): void { - const destinationFolder = newPath.split('/').slice(0, -1).join('/'); - this.logAction(record, 'moved', { - from: oldPath, - to: newPath, - destinationFolder, - progress: `Moving to ${destinationFolder}` - }); - - this.updateRecord(record.id, { - destinationFolder - }); + return Object.values(record.logs).some(log => !!log.error); } - public recordClassification(record: FileRecord, classification: Classification): void { - this.logAction(record, 'classified', { - classification, - wasFormatted: classification.confidence >= 50, - progress: `Classified as ${classification.documentType} (${classification.confidence}% confident)` - }); - - this.updateRecord(record.id, { - classification, - formattedContent: classification.confidence >= 50 - }); + public getStepLogs(hash: string, step: Action): LogEntry | undefined { + const record = this.records.get(hash); + if (!record) return undefined; + return record.logs[step]; } - public recordTags(record: FileRecord, tags: string[]): void { - this.logAction(record, 'tagged', { tags }); - } + public getLastStep(hash: string): Action | null { + const record = this.records.get(hash); + if (!record) return null; - public recordProcessingStep(record: FileRecord, step: string): void { - this.logAction(record, 'processing', { - step, - progress: `Processing ${step}...` - }); - } + const steps = Object.entries(record.logs); + if (steps.length === 0) return null; - public recordAnalysisStart(record: FileRecord): void { - this.logAction(record, 'analyzing', { - step: 'start', - progress: 'Starting content analysis...' - }); - - this.updateRecord(record.id, { - status: 'processing' - }); + return steps.reduce((latest, [action, log]) => { + if (!latest || moment(log.timestamp).isAfter(moment(record.logs[latest].timestamp))) { + return action as Action; + } + return latest; + }, null as Action | null); } - public recordClassificationStart(record: FileRecord): void { - this.logAction(record, 'processing', { - step: 'classification', - progress: 'Determining document type...' - }); + // Query methods for multiple records + public getAllRecords(): FileRecord[] { + return Array.from(this.records.values()); } - public recordFolderSuggestionStart(record: FileRecord): void { - this.logAction(record, 'processing', { - step: 'folder', - progress: 'Suggesting folder location...' - }); + public getRecordsWithErrors(): FileRecord[] { + return this.getAllRecords().filter(record => + Object.values(record.logs).some(log => !!log.error) + ); } - public recordTitleSuggestionStart(record: FileRecord): void { - this.logAction(record, 'processing', { - step: 'title', - progress: 'Generating title suggestions...' - }); + public getRecordsByStep(step: Action): FileRecord[] { + return this.getAllRecords().filter(record => !!record.logs[step]); } } diff --git a/plugin/inbox/types.ts b/plugin/inbox/types.ts index bf942cec..9d4b14bf 100644 --- a/plugin/inbox/types.ts +++ b/plugin/inbox/types.ts @@ -1,6 +1,5 @@ import { TFile } from "obsidian"; -export type FileStatus = "queued" | "processing" | "completed" | "error" | "bypassed"; export type ActionType = 'renamed' | 'moved' | 'classified' | 'tagged' | 'error'; @@ -32,29 +31,6 @@ export interface FileMetadata { isMediaFile: boolean; } -export interface FileRecord { - id: string; - filePath: string; - fileName: string; - previousName: string; - status: FileStatus; - createdAt: string; - updatedAt: string; - metadata: FileMetadata; - errors: Array<{ - timestamp: string; - message: string; - stack?: string; - }>; - newPath?: string; - newName?: string; - tags?: string[]; - actions?: ActionLog[]; - classification?: Classification; - formattedContent?: boolean; - destinationFolder?: string; -} - export interface EventRecord { id: string; fileRecordId: string; diff --git a/plugin/index.ts b/plugin/index.ts index 615934f1..7fbad344 100644 --- a/plugin/index.ts +++ b/plugin/index.ts @@ -1381,7 +1381,7 @@ export default class FileOrganizer extends Plugin { async recommendFolders( content: string, - filePath: string + fileName: string ): Promise { const customInstructions = this.settings.customFolderInstructions; const cutoff = this.settings.contentCutoffChars; @@ -1396,7 +1396,7 @@ export default class FileOrganizer extends Plugin { }, body: JSON.stringify({ content: trimmedContent, - fileName: filePath, + fileName: fileName, folders, customInstructions, }), diff --git a/plugin/views/organizer/components/connectivity-checker.tsx b/plugin/views/organizer/components/connectivity-checker.tsx new file mode 100644 index 00000000..b1f8ffce --- /dev/null +++ b/plugin/views/organizer/components/connectivity-checker.tsx @@ -0,0 +1,76 @@ +import * as React from "react"; +import { ErrorBox } from "./error-box"; +import FileOrganizer from "../../.."; + +interface ConnectivityCheckerProps { + onConnectivityValid: () => void; + plugin: FileOrganizer; +} + +export const ConnectivityChecker: React.FC = ({ + onConnectivityValid, + plugin, +}) => { + const [isChecking, setIsChecking] = React.useState(true); + const [connectivityError, setConnectivityError] = React.useState(null); + + const checkConnectivity = React.useCallback(async () => { + try { + setIsChecking(true); + setConnectivityError(null); + + // First check if we have internet connectivity + const hasInternet = await fetch('https://1.1.1.1/cdn-cgi/trace', { + mode: 'no-cors', + cache: 'no-store' + }) + .then(() => true) + .catch(() => false); + + if (!hasInternet) { + setConnectivityError("No internet connection"); + return; + } + + // Then check if our server is accessible + const serverResponse = await fetch(`${plugin.getServerUrl()}/api/health`, { + method: 'GET', + cache: 'no-store' + }); + + if (!serverResponse.ok) { + setConnectivityError("Cannot connect to server"); + return; + } + + onConnectivityValid(); + } catch (err) { + setConnectivityError("Failed to establish connection"); + } finally { + setIsChecking(false); + } + }, [onConnectivityValid, plugin]); + + React.useEffect(() => { + checkConnectivity(); + }, [checkConnectivity]); + + if (connectivityError) { + return ( + + Retry Connection + + } + /> + ); + } + + return null; +}; \ No newline at end of file diff --git a/plugin/views/organizer/components/inbox-logs.tsx b/plugin/views/organizer/components/inbox-logs.tsx index b5d5fcb2..61b37b8d 100644 --- a/plugin/views/organizer/components/inbox-logs.tsx +++ b/plugin/views/organizer/components/inbox-logs.tsx @@ -1,193 +1,129 @@ import * as React from "react"; -import FileOrganizer from "../../../index"; -import { FileRecord, FileStatus } from "../../../inbox/types"; +import { + FileRecord, + LogEntry, + Action, + FileStatus, +} from "../../../inbox/services/record-manager"; import moment from "moment"; -import { ChevronDown, Search, ExternalLink } from "lucide-react"; -import { App, TFile } from "obsidian"; +import { ChevronDown, Clock, Play, Check, AlertCircle, Ban, Search, Filter } from "lucide-react"; import { motion, AnimatePresence } from "framer-motion"; -import { logMessage } from "../../../someUtils"; import { usePlugin } from "../provider"; -import { logger } from "../../../services/logger"; +import { Inbox } from "../../../inbox"; -interface InboxLogsProps { - plugin: FileOrganizer & { - inbox: { - getAllFiles: () => FileRecord[]; - getQueueStats: () => { - queued: number; - processing: number; - completed: number; - bypassed: number; - errors: number; - }; - }; - app: App; - }; -} - -function calculateStatsFromRecords(records: FileRecord[]) { - return records.reduce( - (acc, record) => { - acc[record.status]++; - return acc; - }, - { - queued: 0, - processing: 0, - completed: 0, - bypassed: 0, - error: 0, +// Simple log entry display component +const LogEntryDisplay: React.FC<{ entry: LogEntry; step: Action }> = ({ + entry, + step, +}) => { + const getDisplayText = (step: Action, completed: boolean) => { + if (completed) { + switch (step) { + case Action.CLEANUP: return "file cleaned up"; + case Action.RENAME: return "file renamed"; + case Action.EXTRACT: return "text extracted"; + case Action.MOVING_ATTACHEMENT: return "attachments moved"; + case Action.CLASSIFY: return "classified"; + case Action.TAGGING: return "tags recommended"; + case Action.APPLYING_TAGS: return "tags applied"; + case Action.RECOMMEND_NAME: return "name recommended"; + case Action.APPLYING_NAME: return "name applied"; + case Action.FORMATTING: return "formatted"; + case Action.MOVING: return "moved"; + case Action.COMPLETED: return "completed"; + default: return step; + } } - ); -} - -// Replace FileDetails component with ActionLog component -const ActionLog: React.FC<{ record: FileRecord }> = ({ record }) => { - if (!record.actions?.length) return null; + return step; + }; return ( -
- {record.actions.map((action, index) => ( -
-
- {moment(action.timestamp).format("HH:mm:ss")} -
- -
- {action.action === "renamed" && ( -
- Renamed from - - {action.details.from} - - to - - {action.details.to} - -
- )} - - {action.action === "moved" && ( -
- Moved to - - {action.details.to} - -
- )} - - {action.action === "classified" && ( -
- Classified as - - {action.details.classification?.documentType} - -
- )} - - {action.action === "tagged" && ( -
- Added tags -
- {action.details.tags?.map((tag, i) => ( - - {tag} - - ))} -
-
- )} - - {action.action === "error" && ( -
{action.details.error}
- )} -
-
- ))} +
+
+ + {moment(entry.timestamp).format("HH:mm:ss")} + + + {getDisplayText(step, entry.completed)} + + {entry.error && ( + {entry.error.message} + )}
); }; -function FileCard({ file }: { file: FileRecord }) { - const [isExpanded, setIsExpanded] = React.useState(false); +// Main file card component +function FileCard({ record }: { record: FileRecord }) { const plugin = usePlugin(); + const [isExpanded, setIsExpanded] = React.useState(false); - // Status indicator colors without text - const getStatusColor = (status: FileStatus) => { - const colors = { - queued: "bg-[--text-warning]", - processing: "bg-[--text-accent]", - completed: "bg-[--text-success]", - error: "bg-[--text-error]", - bypassed: "bg-[--text-muted]", - } as const; - return colors[status] || ""; - }; + // Get sorted actions based on timestamp + const sortedActions = React.useMemo(() => { + return Object.entries(record.logs) + .sort(([, a], [, b]) => moment(b.timestamp).diff(moment(a.timestamp))) + .map(([action]) => action as Action); + }, [record.logs]); return ( -
- {/* Status indicator dot */} -
- - {/* Main content */} -
- {/* Filename section */} -
- {file.newName && file.newName !== file.fileName ? ( -
- - {file.fileName} - - - - plugin.app.workspace.openLinkText( - file.newName, - file.newPath - ) - } - > - {file.newName} - -
- ) : ( - - plugin.app.workspace.openLinkText(file.fileName, file.path) - } - > - {file.fileName} - - )} +
+ {/* Basic file info */} +
+ - - {/* Expand/collapse button */}
+ {/* Always visible info */} +
+ {record.classification && ( +
+ Classification:{" "} + + {record.classification} + +
+ )} + {record.tags.length > 0 && ( +
+ {record.tags.map((tag, i) => ( + + {tag} + + ))} +
+ )} +
+ {/* Expanded content */} {isExpanded && ( @@ -195,208 +131,250 @@ function FileCard({ file }: { file: FileRecord }) { initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} - transition={{ duration: 0.2 }} - className="mt-4 space-y-4 border-t border-[--background-modifier-border] pt-4" + className="mt-4 space-y-2 border-t border-[--background-modifier-border] pt-4" > - {/* Classification */} - {file.classification && ( -
- AI Template: - { - // Add your classification click handler here - plugin.app.workspace.openLinkText( - file.classification.documentType, - plugin.settings.templatePaths - ); - console.log( - "Classification clicked:", - file.classification - ); - }} - > - {file.classification.documentType} - - ({file.classification.confidence}% confidence) - - + {/* Path info */} + {record.newPath && ( +
+ New path:{" "} + {record.newPath}
)} - - {/* Destination folder */} - {file.destinationFolder && ( -
- Location: - {file.destinationFolder} + {record.newName && ( +
+ New name:{" "} + {record.newName}
)} - {/* Tags */} - {file.tags && file.tags.length > 0 && ( -
- Tags: -
- {file.tags.map((tag, index) => ( - - {tag} - - ))} -
+ {/* Actions line */} +
+ Actions: +
+ {sortedActions.map((action) => ( + + {action} + + ))}
- )} +
+ + {/* Logs grouped by step */} +
+ {Object.entries(Action).map(([, action]) => { + const log = record.logs[action]; + if (!log) return null; - {/* Action log */} - + return ( +
+ +
+ ); + })} +
)}
); -} - -export const InboxLogs: React.FC = ({ plugin }) => { - const [files, setFiles] = React.useState([]); - const [sortConfig, setSortConfig] = React.useState<{ - key: keyof FileRecord; - direction: "asc" | "desc"; - }>({ - key: "updatedAt", - direction: "desc", - }); - const [searchTerm, setSearchTerm] = React.useState(""); - const [statusFilter, setStatusFilter] = React.useState("all"); - - const filteredAndSortedFiles = React.useMemo(() => { - return files - .filter(file => { - const matchesSearch = - file.fileName.toLowerCase().includes(searchTerm.toLowerCase()) || - file.status.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesStatus = - statusFilter === "all" || file.status === statusFilter; +}; - return matchesSearch && matchesStatus; - }) - .sort((a, b) => { - const aValue = a[sortConfig.key]; - const bValue = b[sortConfig.key]; +// Status badge component +const StatusBadge: React.FC<{ status: FileStatus }> = ({ status }) => { + const getStatusColor = () => { + switch (status) { + case "completed": + return "bg-[--text-success] bg-opacity-15 text-[--text-success]"; + case "error": + return "bg-[--text-error] bg-opacity-15 text-[--text-error]"; + case "processing": + return "bg-[--text-accent] bg-opacity-15 text-[--text-accent]"; + default: + return "bg-[--background-secondary] text-[--text-muted]"; + } + }; - if (!aValue || !bValue) return 0; + return ( + // make this a small round dot + + {status} + + + ); +}; - if (aValue < bValue) return sortConfig.direction === "asc" ? -1 : 1; - if (aValue > bValue) return sortConfig.direction === "asc" ? 1 : -1; - return 0; - }); - }, [files, sortConfig, searchTerm, statusFilter]); +// Analytics component +const InboxAnalytics: React.FC<{ + analytics: ReturnType; +}> = ({ analytics }) => { + const { byStatus } = analytics; - React.useEffect(() => { - const updateFiles = () => { - const allFiles = plugin.inbox.getAllFiles(); - setFiles(prevFiles => { - if (JSON.stringify(prevFiles) !== JSON.stringify(allFiles)) { - return allFiles; - } - return prevFiles; - }); - }; + // Split statuses into main flow and exceptions + const mainFlow: Array<{ + status: FileStatus; + icon: React.ReactNode; + }> = [ + { status: "queued", icon: }, + { status: "processing", icon: }, + { status: "completed", icon: }, + ]; - updateFiles(); - const interval = setInterval(updateFiles, 100); - return () => clearInterval(interval); - }, [plugin]); - React.useEffect(() => { - logMessage("files", files); - }, [files]); + const exceptions: Array<{ + status: FileStatus; + icon: React.ReactNode; + }> = [ + { status: "error", icon: }, + { status: "bypassed", icon: }, + ]; - const stats = React.useMemo(() => { - return calculateStatsFromRecords(files); - }, [files]); + const StatusBox = ({ status, icon }: { status: FileStatus; icon: React.ReactNode }) => ( +
+
{status}
+
{byStatus[status] || 0}
+
{icon}
+
+ ); return ( -
- {/* Header with stats - numbers are now derived from records */} -
-
-
-
- {stats.queued} -
-
Queued
-
-
-
- {stats.processing} -
-
Processing
-
-
-
- {stats.completed} -
-
Completed
-
-
-
- {stats.bypassed} -
-
Bypassed
-
-
-
- {stats.error} -
-
Errors
-
+
+
+ {/* Main flow row */} +
+ {mainFlow.map(({ status, icon }) => ( + + ))} +
+ + {/* Exceptions row */} +
+ {exceptions.map(({ status, icon }) => ( + + ))}
+
+ ); +}; - {/* Search and filters */} -
-
- +// Search component +interface SearchBarProps { + onSearch: (query: string) => void; + onStatusFilter: (status: FileStatus | "") => void; + selectedStatus: FileStatus | ""; +} + +const SearchBar: React.FC = ({ onSearch, onStatusFilter, selectedStatus }) => { + const [searchQuery, setSearchQuery] = React.useState(""); + + const handleSearchChange = (e: React.ChangeEvent) => { + const query = e.target.value; + setSearchQuery(query); + onSearch(query); + }; + + const statuses: Array = ["", "queued", "processing", "completed", "error", "bypassed"]; + + return ( +
+
+
+ setSearchTerm(e.target.value)} - className="w-full pl-10 pr-4 py-2 rounded bg-[--background-modifier-form-field] border border-[--background-modifier-border] text-[--text-normal]" + placeholder="Search files, tags, or actions..." + value={searchQuery} + onChange={handleSearchChange} + className="w-full pl-9 pr-4 py-2 bg-[--background-secondary] rounded border border-[--background-modifier-border] text-sm" />
- +
+ + +
+
+ ); +}; - {/* File cards */} -
- - {filteredAndSortedFiles.map(file => ( - - ))} - -
+// Main component +export const InboxLogs: React.FC = () => { + const plugin = usePlugin(); + const [records, setRecords] = React.useState([]); + const [filteredRecords, setFilteredRecords] = React.useState([]); + const [analytics, setAnalytics] = React.useState>(); + const [searchQuery, setSearchQuery] = React.useState(""); + const [statusFilter, setStatusFilter] = React.useState(""); + + const filterRecords = React.useCallback((records: FileRecord[]) => { + return records.filter((record) => { + const matchesSearch = searchQuery.toLowerCase().split(" ").every(term => + record.file.basename.toLowerCase().includes(term) || + record.tags.some(tag => tag.toLowerCase().includes(term)) || + Object.keys(record.logs).some(action => action.toLowerCase().includes(term)) || + record.classification?.toLowerCase().includes(term) + ); + + const matchesStatus = !statusFilter || record.status === statusFilter; + + return matchesSearch && matchesStatus; + }); + }, [searchQuery, statusFilter]); + + React.useEffect(() => { + const fetchData = () => { + const files = plugin.inbox.getAllFiles(); + const currentAnalytics = plugin.inbox.getAnalytics(); + setRecords(files); + setFilteredRecords(filterRecords(files)); + setAnalytics(currentAnalytics); + }; + + fetchData(); + const intervalId = setInterval(fetchData, 1000); + return () => clearInterval(intervalId); + }, [plugin.inbox, filterRecords]); - {filteredAndSortedFiles.length === 0 && ( + const handleSearch = (query: string) => { + setSearchQuery(query); + }; + + const handleStatusFilter = (status: FileStatus | "") => { + setStatusFilter(status); + }; + + return ( +
+ {analytics && } + + + + {filteredRecords.map(record => ( + + ))} + {filteredRecords.length === 0 && (
- Drag files in the inbox to automatically organize them. + {records.length === 0 ? "No records found" : "No matching records"}
)}
diff --git a/plugin/views/organizer/components/license-validator.tsx b/plugin/views/organizer/components/license-validator.tsx index 078de535..ffd48f03 100644 --- a/plugin/views/organizer/components/license-validator.tsx +++ b/plugin/views/organizer/components/license-validator.tsx @@ -62,7 +62,7 @@ export const LicenseValidator: React.FC = ({
diff --git a/plugin/views/organizer/organizer.tsx b/plugin/views/organizer/organizer.tsx index 3339e5ac..8cee9bb4 100644 --- a/plugin/views/organizer/organizer.tsx +++ b/plugin/views/organizer/organizer.tsx @@ -17,6 +17,7 @@ import { logMessage } from "../../someUtils"; import { LicenseValidator } from "./components/license-validator"; import { VALID_MEDIA_EXTENSIONS } from "../../constants"; import { logger } from "../../services/logger"; +import { ConnectivityChecker } from "./components/connectivity-checker"; interface AssistantViewProps { plugin: FileOrganizer; @@ -37,6 +38,7 @@ export const AssistantView: React.FC = ({ const [refreshKey, setRefreshKey] = React.useState(0); const [error, setError] = React.useState(null); const [isLicenseValid, setIsLicenseValid] = React.useState(false); + const [isConnected, setIsConnected] = React.useState(false); const isMediaFile = React.useMemo( () => checkIfIsMediaFile(activeFile), @@ -125,6 +127,17 @@ export const AssistantView: React.FC = ({ } }, [activeFile, plugin.app.vault]); + // First check connectivity + if (!isConnected) { + return ( + setIsConnected(true)} + plugin={plugin} + /> + ); + } + + // Then check license if (!isLicenseValid) { return ( { if (process.env.VERCEL_ENV === "production") { @@ -24,7 +23,7 @@ const getTargetUrl = () => { } if (process.env.VERCEL_ENV === "preview") { - return process.env.VERCEL_BRANCH_URL; + return "file-organizer-2000-git-billing-test-prologe.vercel.app"; } return "localhost:3000"; @@ -33,7 +32,6 @@ const getTargetUrl = () => { const targetUrl = getTargetUrl(); const webhookEndpoint = `https://${targetUrl}/api/webhook`; - export const config = { features: features, products: { @@ -67,7 +65,6 @@ export const config = { interval: "year", type: "recurring", trialPeriodDays: 7, // Add trial period for annual plan - }, }, features: [ @@ -114,4 +111,4 @@ export const config = { "payment_intent.succeeded", ], }, -} satisfies PreSRMConfig; \ No newline at end of file +} satisfies PreSRMConfig;