From 13cdd0a1620c75c22eae2c47ee7d9e9585d8c3a3 Mon Sep 17 00:00:00 2001 From: Andrew Beal Date: Mon, 22 Dec 2025 20:02:02 +0000 Subject: [PATCH] feat: add support for file attachments via drag and drop and paste. refactor: standardize file type and MIME type handling across AI providers Introduce centralized MimeType enum and bidirectional mappings between file types and MIME types. Update Claude, Gemini, and OpenAI implementations to use consistent MIME type validation. Add file attachment support via drag-and-drop and paste in chat input. Expand supported file type detection to include programming languages, config files, and documentation formats. --- AIClasses/Claude/Claude.ts | 43 ++- AIClasses/Gemini/Gemini.ts | 83 ++++- AIClasses/OpenAI/OpenAI.ts | 56 +-- Components/ChatArea.svelte | 5 +- Components/ChatAttachments.svelte | 178 ++++++++++ Components/ChatInput.svelte | 84 +++-- Components/ChatSearchResults.svelte | 3 +- Components/ChatWindow.svelte | 6 +- Conversations/Attachment.ts | 8 + Conversations/Conversation.ts | 28 +- Enums/FileType.ts | 342 ++++++++++++++++--- Enums/MimeType.ts | 259 ++++++++++++++ Helpers/Helpers.ts | 5 + Services/AIFunctionService.ts | 4 +- Services/ChatService.ts | 16 +- Services/FileSystemService.ts | 15 + Services/InputService.ts | 78 ++++- Services/VaultService.ts | 23 +- Styles/custom_styles.css | 7 + __tests__/AIClasses/BaseAIClass.test.ts | 3 - __tests__/Conversations/Conversation.test.ts | 1 - 21 files changed, 1082 insertions(+), 165 deletions(-) create mode 100644 Components/ChatAttachments.svelte create mode 100644 Enums/MimeType.ts diff --git a/AIClasses/Claude/Claude.ts b/AIClasses/Claude/Claude.ts index b91c272..0a9356b 100644 --- a/AIClasses/Claude/Claude.ts +++ b/AIClasses/Claude/Claude.ts @@ -10,11 +10,21 @@ import type { ConversationContent } from "Conversations/ConversationContent"; import { Role } from "Enums/Role"; import type { RawMessageStreamEvent, ContentBlockParam, Tool } from '@anthropic-ai/sdk/resources/messages'; import { Exception } from "Helpers/Exception"; +import { MimeType, toMimeType } from "Enums/MimeType"; +import { isTextFile, MimeTypeToFileTypes } from "Enums/FileType"; export class Claude extends BaseAIClass { private readonly STOP_REASON_TOOL_USE: string = "tool_use"; - private readonly SUPPORTED_IMAGE_TYPES: string[] = ["image/jpeg", "image/png", "image/gif", "image/webp"]; + + private readonly SUPPORTED_MIMETYPES = [ + MimeType.TEXT_PLAIN, + MimeType.APPLICATION_PDF, + MimeType.IMAGE_JPEG, + MimeType.IMAGE_PNG, + MimeType.IMAGE_GIF, + MimeType.IMAGE_WEBP + ]; private accumulatedFunctionName: string | null = null; private accumulatedFunctionArgs: string = ""; @@ -257,27 +267,28 @@ export class Claude extends BaseAIClass { public formatBinaryFiles(attachments: Attachment[]): string { const contentBlocks = attachments.flatMap(attachment => { - // Check for uploaded file ID const fileID = attachment.getFileID(this.provider); if (!fileID) { - // Skip - upload failed, error message added in extractContents() - return []; + return []; // Skip - upload failed, error message added in extractContents() } - let blockType: string; - if (attachment.mimeType === "application/pdf") { - blockType = "document"; - } else { - blockType = "image"; - if (!this.SUPPORTED_IMAGE_TYPES.includes(attachment.mimeType)) { - return [{ type: "text", text: `Unsupported image format: ${attachment.fileName}` }]; - } + const mimeType = toMimeType(attachment.mimeType); + + let isPlainText = false; + + // This content can be sent up with the 'MimeType.TEXT_PLAIN' mime type + if (MimeTypeToFileTypes[mimeType].some(fileType => isTextFile(fileType))) { + isPlainText = true; + } + + if (!isPlainText && !this.isSupportedMimeType(mimeType)) { + return [{ type: "text", text: `Unsupported mime type '${mimeType}': ${attachment.fileName}` }]; } return [ {type: "text", text: attachment.fileName}, { - type: blockType, + type: isPlainText || mimeType === MimeType.APPLICATION_PDF ? "document" : "image", source: { type: "file", file_id: fileID @@ -287,4 +298,8 @@ export class Claude extends BaseAIClass { }); return JSON.stringify(contentBlocks); } -} + + private isSupportedMimeType(mimeType: MimeType): boolean { + return this.SUPPORTED_MIMETYPES.includes(mimeType); + } +} \ No newline at end of file diff --git a/AIClasses/Gemini/Gemini.ts b/AIClasses/Gemini/Gemini.ts index 0f0eda8..553c9e0 100644 --- a/AIClasses/Gemini/Gemini.ts +++ b/AIClasses/Gemini/Gemini.ts @@ -10,11 +10,59 @@ import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFun import type { ConversationContent } from "Conversations/ConversationContent"; import type { Candidate, Part, FunctionDeclaration } from "@google/genai"; import { FinishReason } from "@google/genai"; +import { MimeType, toMimeType } from "Enums/MimeType"; +import { isTextFile, MimeTypeToFileTypes } from "Enums/FileType"; export class Gemini extends BaseAIClass { private readonly REQUEST_WEB_SEARCH: string = "request_web_search"; - private readonly SUPPORTED_IMAGE_TYPES: string[] = ["image/jpeg", "image/png"]; + + private readonly SUPPORTED_MIMETYPES = [ + // Common Text + MimeType.TEXT_PLAIN, + MimeType.TEXT_HTML, + MimeType.TEXT_CSS, + MimeType.TEXT_CSV, + MimeType.TEXT_MD, + MimeType.TEXT_MARKDOWN, + MimeType.TEXT_XML, + MimeType.APPLICATION_RTF, + // Images + MimeType.IMAGE_JPEG, + MimeType.IMAGE_PNG, + // Data Formats + MimeType.APPLICATION_JSON, + MimeType.APPLICATION_XML, + // Scripting + MimeType.TEXT_PYTHON, + MimeType.APPLICATION_PYTHON_CODE, + MimeType.TEXT_JAVASCRIPT, + MimeType.APPLICATION_JAVASCRIPT, + MimeType.TEXT_TYPESCRIPT, + MimeType.APPLICATION_TYPESCRIPT, + MimeType.TEXT_SH, + MimeType.APPLICATION_SH, + // C-Family + MimeType.TEXT_C, + MimeType.TEXT_CPP, + MimeType.TEXT_CSRC, + MimeType.TEXT_CPPSRC, + MimeType.TEXT_CHDR, + MimeType.TEXT_CPPHDR, + // Java/JVM + MimeType.TEXT_JAVA, + MimeType.TEXT_JAVA_SOURCE, + MimeType.TEXT_KOTLIN, + MimeType.TEXT_SCALA, + // Others + MimeType.TEXT_GO, + MimeType.TEXT_RUST, + MimeType.TEXT_SWIFT, + MimeType.TEXT_RUBY, + MimeType.TEXT_PHP, + MimeType.TEXT_YAML, + MimeType.APPLICATION_YAML + ]; private accumulatedFunctionName: string | null = null; private accumulatedFunctionArgs: Record = {}; @@ -261,31 +309,38 @@ export class Gemini extends BaseAIClass { const parts: unknown[] = []; for (const attachment of attachments) { - // Check for uploaded file ID const fileID = attachment.getFileID(this.provider); if (!fileID) { - // Skip - upload failed, error message added in extractContents() + continue; // Skip - upload failed, error message added in extractContents() + } + + const mimeType = toMimeType(attachment.mimeType); + + let isPlainText = false; + + // This content can be sent up with the 'MimeType.TEXT_PLAIN' mime type + if (MimeTypeToFileTypes[mimeType].some(fileType => isTextFile(fileType))) { + isPlainText = true; + } + + if (!isPlainText && !this.isSupportedMimeType(mimeType)) { + parts.push({ text: `Unsupported mime type '${mimeType}': ${attachment.fileName}` }); continue; } - // Validate image types (Gemini only supports JPEG and PNG) - if (attachment.mimeType.startsWith('image/')) { - if (!this.SUPPORTED_IMAGE_TYPES.includes(attachment.mimeType)) { - parts.push({ text: `Unsupported image format: ${attachment.fileName}` }); - continue; - } - } - - // Add filename and file data parts.push({text: attachment.fileName}); parts.push({ fileData: { - mimeType: attachment.mimeType, - fileUri: fileID // Format: "files/abc123" + mimeType: mimeType, + fileUri: fileID } }); } return JSON.stringify(parts); } + + private isSupportedMimeType(mimeType: MimeType): boolean { + return this.SUPPORTED_MIMETYPES.includes(mimeType); + } } \ No newline at end of file diff --git a/AIClasses/OpenAI/OpenAI.ts b/AIClasses/OpenAI/OpenAI.ts index 7e76f77..f5ebf14 100644 --- a/AIClasses/OpenAI/OpenAI.ts +++ b/AIClasses/OpenAI/OpenAI.ts @@ -10,10 +10,18 @@ import type { IAIFunctionDefinition } from "AIClasses/FunctionDefinitions/IAIFun import type { ResponseEvent, ResponseOutputTextDelta, ResponseOutputItemDone, ResponseErrorEvent, ResponseFailedEvent, OpenAIFunctionTool, ResponsesAPIInput } from "./OpenAITypes"; import { Exception } from "Helpers/Exception"; import { ApiErrorType } from "Types/ApiError"; +import { MimeType, toMimeType } from "Enums/MimeType"; +import { isTextFile, MimeTypeToFileTypes } from "Enums/FileType"; export class OpenAI extends BaseAIClass { - private readonly SUPPORTED_IMAGE_TYPES: string[] = ["image/jpeg", "image/png", "image/webp"]; + private readonly SUPPORTED_MIMETYPES = [ + MimeType.TEXT_PLAIN, + MimeType.APPLICATION_PDF, + MimeType.IMAGE_JPEG, + MimeType.IMAGE_PNG, + MimeType.IMAGE_WEBP + ]; public constructor() { super(AIProvider.OpenAI); @@ -309,35 +317,29 @@ export class OpenAI extends BaseAIClass { const contentBlocks: unknown[] = []; for (const attachment of attachments) { - // Check for uploaded file ID const fileID = attachment.getFileID(this.provider); if (!fileID) { - // Skip - upload failed, error message added in extractContents() + continue; // Skip - upload failed, error message added in extractContents() + } + + const mimeType = toMimeType(attachment.mimeType); + + let isPlainText = false; + + //This content can be sent up with the 'MimeType.TEXT_PLAIN' mime type + if (MimeTypeToFileTypes[mimeType].some(fileType => isTextFile(fileType))) { + isPlainText = true; + } + + if (!isPlainText && !this.isSupportedMimeType(mimeType)) { + contentBlocks.push([{ type: "input_text", text: `Unsupported mime type '${mimeType}': ${attachment.fileName}` }]); continue; } - if (attachment.mimeType === "application/pdf") { - // Use file ID format for PDFs - contentBlocks.push({ - type: "input_file", - file_id: fileID - }); - } else { - // Images - if (!this.SUPPORTED_IMAGE_TYPES.includes(attachment.mimeType)) { - contentBlocks.push({ - type: "input_text", - text: `Unsupported image format: ${attachment.fileName}` - }); - continue; - } - - // Use file ID format for images - contentBlocks.push({ - type: "input_image", - file_id: fileID - }); - } + contentBlocks.push({ + type: isPlainText || mimeType === MimeType.APPLICATION_PDF ? "input_file" : "input_image", + file_id: fileID + }); } return JSON.stringify([{ @@ -345,4 +347,8 @@ export class OpenAI extends BaseAIClass { content: contentBlocks }]); } + + private isSupportedMimeType(mimeType: MimeType): boolean { + return this.SUPPORTED_MIMETYPES.includes(mimeType); + } } \ No newline at end of file diff --git a/Components/ChatArea.svelte b/Components/ChatArea.svelte index 4696e87..2eac1a2 100644 --- a/Components/ChatArea.svelte +++ b/Components/ChatArea.svelte @@ -250,7 +250,7 @@
{#if messages.length > 0} -
+
{/if}
{#each messages as message, index} @@ -300,7 +300,7 @@
{#if messages.length > 0} -
+
{/if}
@@ -390,6 +390,7 @@ font-size: var(--font-ui-medium); color: var(--text-muted); pointer-events: none; + user-select: none; } .streaming-content { diff --git a/Components/ChatAttachments.svelte b/Components/ChatAttachments.svelte new file mode 100644 index 0000000..474b1ce --- /dev/null +++ b/Components/ChatAttachments.svelte @@ -0,0 +1,178 @@ + + +
+ {#each attachments as attachment, index} +
handleKeydown(e, attachment)} + on:mouseover={handleFocus} + on:mouseleave={handleBlur} + on:focus={handleFocus} + on:blur={handleBlur} + bind:this={attachmentElements[index]}> +
+
+
{attachment.fileName}
+
{attachment.approximateFileSizeMB()}MB
+
+ +
+ {/each} +
+ + diff --git a/Components/ChatInput.svelte b/Components/ChatInput.svelte index 8138ee7..933b130 100644 --- a/Components/ChatInput.svelte +++ b/Components/ChatInput.svelte @@ -14,6 +14,10 @@ import type { EventService } from "Services/EventService"; import { Event } from "Enums/Event"; import type { DiffService } from "Services/DiffService"; + import type { Attachment } from "Conversations/Attachment"; + import ChatAttachments from "./ChatAttachments.svelte"; + + export let attachments: Attachment[] = []; export let hasNoApiKey: boolean; export let isSubmitting: boolean; @@ -35,8 +39,9 @@ let submitButton: HTMLButtonElement; let editModeButton: HTMLButtonElement; - let userInstructionActive = false; - let userRequest = ""; + let userInstructionActive: boolean = false; + + let userRequest: string = ""; let diffOpen: boolean = false; @@ -227,19 +232,6 @@ } } - function handlePaste(e: ClipboardEvent) { - e.preventDefault(); - - const plainText = inputService.getPlainTextFromClipboard(e.clipboardData); - - if (!plainText) { - return; - } - - inputService.insertTextAtCursor(plainText); - handleInput(); - } - function handleCopy(e: ClipboardEvent) { e.preventDefault(); @@ -253,10 +245,17 @@ e.clipboardData?.setData("text/plain", selectedText); } - function handleDrop(e: DragEvent) { + async function handleDataTransfer(e: ClipboardEvent | DragEvent) { e.preventDefault(); + e.stopPropagation(); - const plainText = e.dataTransfer?.getData("text/plain") || ""; + const dataTransfer = e instanceof ClipboardEvent ? e.clipboardData : e.dataTransfer; + + const files = await inputService.getFilesFromDataTransfer(dataTransfer); + const plainText = inputService.getTextFromDataTransfer(dataTransfer); + + const newAttachments = files.filter(file => !attachments.some(attachment => attachment.base64 === file.base64)); + attachments = [...attachments, ...newAttachments]; if (!plainText) { return; @@ -266,6 +265,25 @@ handleInput(); } + function handleDragOver(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + if (e.dataTransfer) { + e.dataTransfer.dropEffect = "copy"; + e.dataTransfer.effectAllowed = "copy"; + } + } + + function handleDragEnter(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + } + + function handleDragLeave(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + } + function handleCursorPositionChange() { if (!$searchState.active || $searchState.position === null) { return; @@ -284,6 +302,10 @@
+
0 ? "var(--size-4-2)" : 0}> + +
+
@@ -313,9 +335,12 @@ on:keydown={handleKeydown} on:beforeinput={handleBeforeInput} on:input={handleInput} - on:paste={handlePaste} on:copy={handleCopy} - on:drop={handleDrop} + on:paste={handleDataTransfer} + on:drop={handleDataTransfer} + on:dragover={handleDragOver} + on:dragenter={handleDragEnter} + on:dragleave={handleDragLeave} on:click={handleCursorPositionChange} on:keyup={handleCursorPositionChange} on:focusout={handleFocusOut} @@ -355,7 +380,7 @@ grid-row: 2; grid-column: 1; display: grid; - grid-template-rows: auto auto var(--size-4-3) 1fr var(--size-4-3); + grid-template-rows: auto auto auto var(--size-4-3) 1fr var(--size-4-3); grid-template-columns: var(--size-4-3) auto var(--size-4-2) 1fr var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3); border-radius: var(--modal-radius); background-color: var(--background-primary); @@ -366,23 +391,28 @@ transition: border-color 0.5s ease-out; } - #diff-controls-container { + #input-attachments-container { grid-row: 1; grid-column: 2 / 9; } - #input-search-results-container { + #diff-controls-container { grid-row: 2; grid-column: 2 / 9; } + #input-search-results-container { + grid-row: 3; + grid-column: 2 / 9; + } + #user-instruction-container { - grid-row: 2; + grid-row: 3; grid-column: 2 / 9; } #user-instruction-button { - grid-row: 4; + grid-row: 5; grid-column: 2; border-radius: var(--button-radius); align-self: end; @@ -398,7 +428,7 @@ } #input-field { - grid-row: 4; + grid-row: 5; grid-column: 4; height: 100%; max-height: 30vh; @@ -459,7 +489,7 @@ } #edit-mode-button { - grid-row: 4; + grid-row: 5; grid-column: 6; border-radius: var(--button-radius); align-self: end; @@ -471,7 +501,7 @@ } #submit-button { - grid-row: 4; + grid-row: 5; grid-column: 8; border-radius: var(--button-radius); padding-left: var(--size-4-5); diff --git a/Components/ChatSearchResults.svelte b/Components/ChatSearchResults.svelte index c973f2c..738c51a 100644 --- a/Components/ChatSearchResults.svelte +++ b/Components/ChatSearchResults.svelte @@ -45,7 +45,7 @@ case SearchTrigger.Tag: return "tag"; default: - return "file-question-mark"; + return "file"; } } @@ -121,7 +121,6 @@ padding: var(--size-2-2) var(--size-4-2); cursor: pointer; position: relative; - z-index: 10; } .input-search-result-icon { diff --git a/Components/ChatWindow.svelte b/Components/ChatWindow.svelte index 79fb923..519bccf 100644 --- a/Components/ChatWindow.svelte +++ b/Components/ChatWindow.svelte @@ -15,6 +15,7 @@ import type { SettingsService } from "Services/SettingsService"; import { Copy } from "Enums/Copy"; import { AbortService } from "Services/AbortService"; + import type { Attachment } from "Conversations/Attachment"; const plugin: VaultkeeperAIPlugin = Resolve(Services.VaultkeeperAIPlugin); const settingsService: SettingsService = Resolve(Services.SettingsService); @@ -34,6 +35,7 @@ let currentStreamingMessageId: string | null = null; let conversation: Conversation = new Conversation(); + let attachments: Attachment[] = []; let currentThought: string | null = null; @@ -97,10 +99,11 @@ const currentRequest = userRequest; - await chatService.submit(conversation, editModeActive, currentRequest, formattedRequest, { + await chatService.submit(conversation, editModeActive, currentRequest, formattedRequest, attachments, { onSubmit: () => { chatArea.updateChatAreaLayout("smooth"); isSubmitting = true; + attachments = []; }, onStreamingUpdate: (streamingId) => { conversation = conversation; @@ -172,6 +175,7 @@ 0) { const attachments = binaryResults.map(file => { - // Extract filename from path const fileName = file.path.split('/').pop() || file.path; - - // Determine mimeType based on file.type - let mimeType: string; - if (isFileType(file.type, FileType.PDF)) { - mimeType = "application/pdf"; - } else { - // For images, derive from extension - const extension = fileName.split('.').pop()?.toLowerCase() || ''; - try { - mimeType = getImageMimeType(extension); - } catch (error) { - Exception.log(error); - Exception.throw(error); - } - } - + const mimeType = FileTypeToMimeType[toFileType(file.type)]; + return new Attachment(fileName, mimeType, file.contents); }); diff --git a/Enums/FileType.ts b/Enums/FileType.ts index 089d9be..10c3dda 100644 --- a/Enums/FileType.ts +++ b/Enums/FileType.ts @@ -1,13 +1,10 @@ -import { Exception } from "Helpers/Exception"; +import { MimeType } from "./MimeType"; export enum FileType { - // ----- Types officially supported by Obsidian ----- - MD = "md", BASE = "base", CANVAS = "canvas", - // images AVIF = "avif", BMP = "bmp", @@ -17,9 +14,7 @@ export enum FileType { PNG = "png", SVG = "svg", WEBP = "webp", - WEBM = "webm", - // audio FLAC = "flac", M4A = "m4a", @@ -28,66 +23,136 @@ export enum FileType { WAV = "wav", WEBM_AUDIO = WEBM, THREE_GP = "3gp", - // video MKV = "mkv", MOV = "mov", MP4 = "mp4", OGV = "ogv", WEBM_VIDEO = WEBM, - // pdf PDF = "pdf", - + // ----- Types not officially supported by Obsidian ----- - + // Plain text TXT = "txt", + TEXT = "text", + + // Data formats JSON = "json", XML = "xml", - HTML = "html", CSV = "csv", + TSV = "tsv", YAML = "yaml", YML = "yml", + TOML = "toml", + + // Web languages + HTML = "html", CSS = "css", + SASS = "sass", + SCSS = "scss", + + // JavaScript/TypeScript ecosystem JS = "js", + MJS = "mjs", + CJS = "cjs", TS = "ts", JSX = "jsx", TSX = "tsx", - SASS = "sass", - SCSS = "scss", + VUE = "vue", + SVELTE = "svelte", + ASTRO = "astro", + + // Shell/Scripting + SH = "sh", + BASH = "bash", + ZSH = "zsh", + FISH = "fish", + BAT = "bat", + CMD = "cmd", + PS1 = "ps1", + + // Programming languages + PY = "py", + RB = "rb", + PHP = "php", + JAVA = "java", + C = "c", + CPP = "cpp", + CC = "cc", + CXX = "cxx", + H = "h", + HPP = "hpp", + HXX = "hxx", + CS = "cs", + GO = "go", + RS = "rs", + SWIFT = "swift", + KT = "kt", + KTS = "kts", + SCALA = "scala", + M = "m", + R = "r", + R_UPPER = "R", + JL = "jl", + LUA = "lua", + PL = "pl", + PM = "pm", + DART = "dart", + + // Markup & Documentation TEX = "tex", + LATEX = "latex", + RST = "rst", + ADOC = "adoc", + ASCIIDOC = "asciidoc", + ORG = "org", + TEXTILE = "textile", + RTF = "rtf", + + // Configuration files INI = "ini", CFG = "cfg", CONF = "conf", + ENV = "env", + PROPERTIES = "properties", + GITIGNORE = "gitignore", + GITATTRIBUTES = "gitattributes", + EDITORCONFIG = "editorconfig", + PRETTIERRC = "prettierrc", + ESLINTRC = "eslintrc", + BABELRC = "babelrc", + NPMRC = "npmrc", + YARNRC = "yarnrc", + DOCKERFILE = "dockerfile", + + // Query languages + SQL = "sql", + GRAPHQL = "graphql", + GQL = "gql", + + // Build & Development + MAKEFILE = "makefile", + MK = "mk", + GRADLE = "gradle", + DIFF = "diff", + PATCH = "patch", + + // Logs LOG = "log", - TOML = "toml", - RTF = "rtf", + + UNKNOWN = "unknown" } -export function getImageMimeType(extension: string): string { - switch (extension.toLowerCase()) { - case FileType.AVIF as string: - return "image/avif"; - case FileType.BMP as string: - return "image/bmp"; - case FileType.GIF as string: - return "image/gif"; - case FileType.JPEG as string: - case FileType.JPG as string: - return "image/jpeg"; - case FileType.PNG as string: - return "image/png"; - case FileType.SVG as string: - return "image/svg+xml"; - case FileType.WEBP as string: - return "image/webp"; - default: - Exception.throw(`Image type not supported: ${extension}`); +export function toFileType(fileType: string): FileType { + if (isKnownFileType(fileType)) { + return fileType; } + return FileType.UNKNOWN; } export function isKnownFileType(value: string): value is FileType { - return Object.values(FileType).includes(value as FileType); + return Object.values(FileType).includes(value as FileType) && value !== FileType.UNKNOWN.toString(); } export function isFileType(value: string, fileType: FileType) { @@ -103,24 +168,217 @@ export function isTextFile(extension: string) { || isFileType(extension, FileType.BASE) || isFileType(extension, FileType.CANVAS) || isFileType(extension, FileType.TXT) + || isFileType(extension, FileType.TEXT) || isFileType(extension, FileType.JSON) || isFileType(extension, FileType.XML) - || isFileType(extension, FileType.HTML) || isFileType(extension, FileType.CSV) + || isFileType(extension, FileType.TSV) || isFileType(extension, FileType.YAML) || isFileType(extension, FileType.YML) + || isFileType(extension, FileType.TOML) + || isFileType(extension, FileType.HTML) || isFileType(extension, FileType.CSS) + || isFileType(extension, FileType.SASS) + || isFileType(extension, FileType.SCSS) || isFileType(extension, FileType.JS) + || isFileType(extension, FileType.MJS) + || isFileType(extension, FileType.CJS) || isFileType(extension, FileType.TS) || isFileType(extension, FileType.JSX) || isFileType(extension, FileType.TSX) - || isFileType(extension, FileType.SASS) - || isFileType(extension, FileType.SCSS) + || isFileType(extension, FileType.VUE) + || isFileType(extension, FileType.SVELTE) + || isFileType(extension, FileType.ASTRO) + || isFileType(extension, FileType.SH) + || isFileType(extension, FileType.BASH) + || isFileType(extension, FileType.ZSH) + || isFileType(extension, FileType.FISH) + || isFileType(extension, FileType.BAT) + || isFileType(extension, FileType.CMD) + || isFileType(extension, FileType.PS1) + || isFileType(extension, FileType.PY) + || isFileType(extension, FileType.RB) + || isFileType(extension, FileType.PHP) + || isFileType(extension, FileType.JAVA) + || isFileType(extension, FileType.C) + || isFileType(extension, FileType.CPP) + || isFileType(extension, FileType.CC) + || isFileType(extension, FileType.CXX) + || isFileType(extension, FileType.H) + || isFileType(extension, FileType.HPP) + || isFileType(extension, FileType.HXX) + || isFileType(extension, FileType.CS) + || isFileType(extension, FileType.GO) + || isFileType(extension, FileType.RS) + || isFileType(extension, FileType.SWIFT) + || isFileType(extension, FileType.KT) + || isFileType(extension, FileType.KTS) + || isFileType(extension, FileType.SCALA) + || isFileType(extension, FileType.M) + || isFileType(extension, FileType.R) + || isFileType(extension, FileType.R_UPPER) + || isFileType(extension, FileType.JL) + || isFileType(extension, FileType.LUA) + || isFileType(extension, FileType.PL) + || isFileType(extension, FileType.PM) + || isFileType(extension, FileType.DART) || isFileType(extension, FileType.TEX) + || isFileType(extension, FileType.LATEX) + || isFileType(extension, FileType.RST) + || isFileType(extension, FileType.ADOC) + || isFileType(extension, FileType.ASCIIDOC) + || isFileType(extension, FileType.ORG) + || isFileType(extension, FileType.TEXTILE) + || isFileType(extension, FileType.RTF) || isFileType(extension, FileType.INI) || isFileType(extension, FileType.CFG) || isFileType(extension, FileType.CONF) - || isFileType(extension, FileType.LOG) - || isFileType(extension, FileType.TOML) - || isFileType(extension, FileType.RTF); -} \ No newline at end of file + || isFileType(extension, FileType.ENV) + || isFileType(extension, FileType.PROPERTIES) + || isFileType(extension, FileType.GITIGNORE) + || isFileType(extension, FileType.GITATTRIBUTES) + || isFileType(extension, FileType.EDITORCONFIG) + || isFileType(extension, FileType.PRETTIERRC) + || isFileType(extension, FileType.ESLINTRC) + || isFileType(extension, FileType.BABELRC) + || isFileType(extension, FileType.NPMRC) + || isFileType(extension, FileType.YARNRC) + || isFileType(extension, FileType.DOCKERFILE) + || isFileType(extension, FileType.SQL) + || isFileType(extension, FileType.GRAPHQL) + || isFileType(extension, FileType.GQL) + || isFileType(extension, FileType.MAKEFILE) + || isFileType(extension, FileType.MK) + || isFileType(extension, FileType.GRADLE) + || isFileType(extension, FileType.DIFF) + || isFileType(extension, FileType.PATCH) + || isFileType(extension, FileType.LOG); +} + +export function isImageFile(extension: string) { + return isFileType(extension, FileType.AVIF) + || isFileType(extension, FileType.BMP) + || isFileType(extension, FileType.GIF) + || isFileType(extension, FileType.JPEG) + || isFileType(extension, FileType.JPG) + || isFileType(extension, FileType.PNG) + || isFileType(extension, FileType.SVG) + || isFileType(extension, FileType.WEBP); +} + +export function isAudioFile(extension: string) { + return isFileType(extension, FileType.FLAC) + || isFileType(extension, FileType.M4A) + || isFileType(extension, FileType.MP3) + || isFileType(extension, FileType.OGG) + || isFileType(extension, FileType.WAV) + || isFileType(extension, FileType.WEBM_AUDIO) + || isFileType(extension, FileType.THREE_GP); +} + +export function isVideoFile(extension: string) { + return isFileType(extension, FileType.MKV) + || isFileType(extension, FileType.MOV) + || isFileType(extension, FileType.MP4) + || isFileType(extension, FileType.OGV) + || isFileType(extension, FileType.WEBM_VIDEO); +} + +export const MimeTypeToFileTypes: Record = { + // Text + [MimeType.TEXT_PLAIN]: [FileType.BASE, FileType.TXT, FileType.TEXT, FileType.INI, FileType.CFG, FileType.CONF, FileType.ENV, FileType.PROPERTIES, FileType.LOG, FileType.GITIGNORE, FileType.GITATTRIBUTES, FileType.EDITORCONFIG, FileType.PRETTIERRC, FileType.ESLINTRC, FileType.BABELRC, FileType.NPMRC, FileType.YARNRC, FileType.DIFF, FileType.PATCH], + [MimeType.TEXT_MARKDOWN]: [FileType.MD], + [MimeType.TEXT_MD]: [FileType.MD], + [MimeType.TEXT_HTML]: [FileType.HTML], + [MimeType.TEXT_CSS]: [FileType.CSS], + [MimeType.TEXT_CSV]: [FileType.CSV], + [MimeType.TEXT_TSV]: [FileType.TSV], + [MimeType.TEXT_JAVASCRIPT]: [FileType.JS, FileType.MJS, FileType.CJS], + [MimeType.TEXT_TYPESCRIPT]: [FileType.TS], + [MimeType.TEXT_JSX]: [FileType.JSX], + [MimeType.TEXT_TSX]: [FileType.TSX], + [MimeType.TEXT_SASS]: [FileType.SASS], + [MimeType.TEXT_SCSS]: [FileType.SCSS], + [MimeType.TEXT_VUE]: [FileType.VUE], + [MimeType.TEXT_SVELTE]: [FileType.SVELTE], + [MimeType.TEXT_PYTHON]: [FileType.PY], + [MimeType.TEXT_RUBY]: [FileType.RB], + [MimeType.TEXT_PHP]: [FileType.PHP], + [MimeType.TEXT_JAVA]: [FileType.JAVA], + [MimeType.TEXT_JAVA_SOURCE]: [FileType.JAVA], + [MimeType.TEXT_C]: [FileType.C, FileType.H], + [MimeType.TEXT_CSRC]: [FileType.C], + [MimeType.TEXT_CHDR]: [FileType.H], + [MimeType.TEXT_CPP]: [FileType.CPP, FileType.CC, FileType.CXX, FileType.HPP, FileType.HXX], + [MimeType.TEXT_CPPSRC]: [FileType.CPP, FileType.CC, FileType.CXX], + [MimeType.TEXT_CPPHDR]: [FileType.HPP, FileType.HXX], + [MimeType.TEXT_CSHARP]: [FileType.CS], + [MimeType.TEXT_GO]: [FileType.GO], + [MimeType.TEXT_RUST]: [FileType.RS], + [MimeType.TEXT_SWIFT]: [FileType.SWIFT], + [MimeType.TEXT_KOTLIN]: [FileType.KT, FileType.KTS], + [MimeType.TEXT_SCALA]: [FileType.SCALA], + [MimeType.TEXT_R]: [FileType.R, FileType.R_UPPER], + [MimeType.TEXT_JULIA]: [FileType.JL], + [MimeType.TEXT_LUA]: [FileType.LUA], + [MimeType.TEXT_PERL]: [FileType.PL, FileType.PM], + [MimeType.TEXT_DART]: [FileType.DART], + [MimeType.TEXT_SHELL]: [FileType.SH, FileType.BASH, FileType.ZSH, FileType.FISH], + [MimeType.TEXT_SH]: [FileType.SH, FileType.BASH, FileType.ZSH, FileType.FISH], + [MimeType.TEXT_BATCH]: [FileType.BAT, FileType.CMD], + [MimeType.TEXT_POWERSHELL]: [FileType.PS1], + [MimeType.TEXT_SQL]: [FileType.SQL], + [MimeType.TEXT_GRAPHQL]: [FileType.GRAPHQL, FileType.GQL], + [MimeType.TEXT_XML]: [FileType.XML], + [MimeType.TEXT_YAML]: [FileType.YAML, FileType.YML], + + // Application + [MimeType.APPLICATION_JSON]: [FileType.CANVAS, FileType.JSON], + [MimeType.APPLICATION_XML]: [FileType.XML], + [MimeType.APPLICATION_PDF]: [FileType.PDF], + [MimeType.APPLICATION_RTF]: [FileType.RTF], + [MimeType.APPLICATION_YAML]: [FileType.YAML, FileType.YML], + [MimeType.APPLICATION_TOML]: [FileType.TOML], + [MimeType.APPLICATION_TEX]: [FileType.TEX], + [MimeType.APPLICATION_LATEX]: [FileType.LATEX], + [MimeType.APPLICATION_MAKEFILE]: [FileType.MAKEFILE, FileType.MK], + [MimeType.APPLICATION_GRADLE]: [FileType.GRADLE], + [MimeType.APPLICATION_DOCKERFILE]: [FileType.DOCKERFILE], + [MimeType.APPLICATION_PYTHON_CODE]: [FileType.PY], + [MimeType.APPLICATION_JAVASCRIPT]: [FileType.JS, FileType.MJS, FileType.CJS], + [MimeType.APPLICATION_TYPESCRIPT]: [FileType.TS], + [MimeType.APPLICATION_SH]: [FileType.SH, FileType.BASH, FileType.ZSH, FileType.FISH], + + // Markup formats + [MimeType.TEXT_RST]: [FileType.RST], + [MimeType.TEXT_ASCIIDOC]: [FileType.ADOC, FileType.ASCIIDOC], + [MimeType.TEXT_ORG]: [FileType.ORG], + [MimeType.TEXT_TEXTILE]: [FileType.TEXTILE], + + // Images + [MimeType.IMAGE_AVIF]: [FileType.AVIF], + [MimeType.IMAGE_BMP]: [FileType.BMP], + [MimeType.IMAGE_GIF]: [FileType.GIF], + [MimeType.IMAGE_JPEG]: [FileType.JPEG, FileType.JPG], + [MimeType.IMAGE_PNG]: [FileType.PNG], + [MimeType.IMAGE_SVG]: [FileType.SVG], + [MimeType.IMAGE_WEBP]: [FileType.WEBP], + + // Audio + [MimeType.AUDIO_FLAC]: [FileType.FLAC], + [MimeType.AUDIO_MP4]: [FileType.M4A], + [MimeType.AUDIO_MPEG]: [FileType.MP3], + [MimeType.AUDIO_OGG]: [FileType.OGG], + [MimeType.AUDIO_WAV]: [FileType.WAV], + [MimeType.AUDIO_WEBM]: [FileType.WEBM_AUDIO], + + // Video + [MimeType.VIDEO_3GPP]: [FileType.THREE_GP], + [MimeType.VIDEO_MATROSKA]: [FileType.MKV], + [MimeType.VIDEO_QUICKTIME]: [FileType.MOV], + [MimeType.VIDEO_MP4]: [FileType.MP4], + [MimeType.VIDEO_OGG]: [FileType.OGV], + [MimeType.VIDEO_WEBM]: [FileType.WEBM, FileType.WEBM_VIDEO], + + [MimeType.UNKNOWN]: [FileType.UNKNOWN] +}; \ No newline at end of file diff --git a/Enums/MimeType.ts b/Enums/MimeType.ts new file mode 100644 index 0000000..3f8f95b --- /dev/null +++ b/Enums/MimeType.ts @@ -0,0 +1,259 @@ +import { FileType } from "./FileType"; + +export enum MimeType { + // Text + TEXT_PLAIN = "text/plain", + TEXT_MARKDOWN = "text/markdown", + TEXT_MD = "text/md", + TEXT_HTML = "text/html", + TEXT_CSS = "text/css", + TEXT_CSV = "text/csv", + TEXT_TSV = "text/tab-separated-values", + TEXT_JAVASCRIPT = "text/javascript", + TEXT_TYPESCRIPT = "text/typescript", + TEXT_JSX = "text/jsx", + TEXT_TSX = "text/tsx", + TEXT_SASS = "text/x-sass", + TEXT_SCSS = "text/x-scss", + TEXT_VUE = "text/x-vue", + TEXT_SVELTE = "text/x-svelte", + TEXT_PYTHON = "text/x-python", + TEXT_RUBY = "text/x-ruby", + TEXT_PHP = "text/x-php", + TEXT_JAVA = "text/x-java", + TEXT_JAVA_SOURCE = "text/x-java-source", + TEXT_C = "text/x-c", + TEXT_CSRC = "text/x-csrc", + TEXT_CHDR = "text/x-chdr", + TEXT_CPP = "text/x-c++", + TEXT_CPPSRC = "text/x-c++src", + TEXT_CPPHDR = "text/x-c++hdr", + TEXT_CSHARP = "text/x-csharp", + TEXT_GO = "text/x-go", + TEXT_RUST = "text/x-rust", + TEXT_SWIFT = "text/x-swift", + TEXT_KOTLIN = "text/x-kotlin", + TEXT_SCALA = "text/x-scala", + TEXT_R = "text/x-r", + TEXT_JULIA = "text/x-julia", + TEXT_LUA = "text/x-lua", + TEXT_PERL = "text/x-perl", + TEXT_DART = "text/x-dart", + TEXT_SHELL = "text/x-shellscript", + TEXT_SH = "text/x-sh", + TEXT_BATCH = "text/x-batch", + TEXT_POWERSHELL = "text/x-powershell", + TEXT_SQL = "text/x-sql", + TEXT_GRAPHQL = "text/x-graphql", + TEXT_XML = "text/xml", + TEXT_YAML = "text/x-yaml", + + // Application + APPLICATION_JSON = "application/json", + APPLICATION_XML = "application/xml", + APPLICATION_PDF = "application/pdf", + APPLICATION_RTF = "application/rtf", + APPLICATION_YAML = "application/x-yaml", + APPLICATION_TOML = "application/toml", + APPLICATION_TEX = "application/x-tex", + APPLICATION_LATEX = "application/x-latex", + APPLICATION_MAKEFILE = "text/x-makefile", + APPLICATION_GRADLE = "text/x-gradle", + APPLICATION_DOCKERFILE = "text/x-dockerfile", + APPLICATION_PYTHON_CODE = "application/x-python-code", + APPLICATION_JAVASCRIPT = "application/x-javascript", + APPLICATION_TYPESCRIPT = "application/x-typescript", + APPLICATION_SH = "application/x-sh", + + // Markup formats + TEXT_RST = "text/x-rst", + TEXT_ASCIIDOC = "text/x-asciidoc", + TEXT_ORG = "text/x-org", + TEXT_TEXTILE = "text/x-textile", + + // Images + IMAGE_AVIF = "image/avif", + IMAGE_BMP = "image/bmp", + IMAGE_GIF = "image/gif", + IMAGE_JPEG = "image/jpeg", + IMAGE_PNG = "image/png", + IMAGE_SVG = "image/svg+xml", + IMAGE_WEBP = "image/webp", + + // Audio + AUDIO_FLAC = "audio/flac", + AUDIO_MP4 = "audio/mp4", + AUDIO_MPEG = "audio/mpeg", + AUDIO_OGG = "audio/ogg", + AUDIO_WAV = "audio/wav", + AUDIO_WEBM = "audio/webm", + + // Video + VIDEO_3GPP = "video/3gpp", + VIDEO_MATROSKA = "video/x-matroska", + VIDEO_QUICKTIME = "video/quicktime", + VIDEO_MP4 = "video/mp4", + VIDEO_OGG = "video/ogg", + VIDEO_WEBM = "video/webm", + + UNKNOWN = "unknown" +} + +export function toMimeType(mimeType: string): MimeType { + if (isKnownMimeType(mimeType)) { + return mimeType; + } + return MimeType.UNKNOWN; +} + +export function isKnownMimeType(value: string): value is MimeType { + return Object.values(MimeType).includes(value as MimeType) && value !== MimeType.UNKNOWN.toString(); +} + +export const FileTypeToMimeType: Record = { + // ----- Types officially supported by Obsidian ----- + [FileType.MD]: MimeType.TEXT_MARKDOWN, + [FileType.BASE]: MimeType.TEXT_PLAIN, + [FileType.CANVAS]: MimeType.APPLICATION_JSON, + + // images + [FileType.AVIF]: MimeType.IMAGE_AVIF, + [FileType.BMP]: MimeType.IMAGE_BMP, + [FileType.GIF]: MimeType.IMAGE_GIF, + [FileType.JPEG]: MimeType.IMAGE_JPEG, + [FileType.JPG]: MimeType.IMAGE_JPEG, + [FileType.PNG]: MimeType.IMAGE_PNG, + [FileType.SVG]: MimeType.IMAGE_SVG, + [FileType.WEBP]: MimeType.IMAGE_WEBP, + + // Note: WEBM is used for both audio and video, defaulting to video + [FileType.WEBM]: MimeType.VIDEO_WEBM, + + // audio + [FileType.FLAC]: MimeType.AUDIO_FLAC, + [FileType.M4A]: MimeType.AUDIO_MP4, + [FileType.MP3]: MimeType.AUDIO_MPEG, + [FileType.OGG]: MimeType.AUDIO_OGG, + [FileType.WAV]: MimeType.AUDIO_WAV, + [FileType.THREE_GP]: MimeType.VIDEO_3GPP, + + // video + [FileType.MKV]: MimeType.VIDEO_MATROSKA, + [FileType.MOV]: MimeType.VIDEO_QUICKTIME, + [FileType.MP4]: MimeType.VIDEO_MP4, + [FileType.OGV]: MimeType.VIDEO_OGG, + + // pdf + [FileType.PDF]: MimeType.APPLICATION_PDF, + + // ----- Types not officially supported by Obsidian ----- + // Plain text + [FileType.TXT]: MimeType.TEXT_PLAIN, + [FileType.TEXT]: MimeType.TEXT_PLAIN, + + // Data formats + [FileType.JSON]: MimeType.APPLICATION_JSON, + [FileType.XML]: MimeType.APPLICATION_XML, + [FileType.CSV]: MimeType.TEXT_CSV, + [FileType.TSV]: MimeType.TEXT_TSV, + [FileType.YAML]: MimeType.APPLICATION_YAML, + [FileType.YML]: MimeType.APPLICATION_YAML, + [FileType.TOML]: MimeType.APPLICATION_TOML, + + // Web languages + [FileType.HTML]: MimeType.TEXT_HTML, + [FileType.CSS]: MimeType.TEXT_CSS, + [FileType.SASS]: MimeType.TEXT_SASS, + [FileType.SCSS]: MimeType.TEXT_SCSS, + + // JavaScript/TypeScript ecosystem + [FileType.JS]: MimeType.TEXT_JAVASCRIPT, + [FileType.MJS]: MimeType.TEXT_JAVASCRIPT, + [FileType.CJS]: MimeType.TEXT_JAVASCRIPT, + [FileType.TS]: MimeType.TEXT_TYPESCRIPT, + [FileType.JSX]: MimeType.TEXT_JSX, + [FileType.TSX]: MimeType.TEXT_TSX, + [FileType.VUE]: MimeType.TEXT_VUE, + [FileType.SVELTE]: MimeType.TEXT_SVELTE, + [FileType.ASTRO]: MimeType.TEXT_PLAIN, + + // Shell/Scripting + [FileType.SH]: MimeType.TEXT_SHELL, + [FileType.BASH]: MimeType.TEXT_SHELL, + [FileType.ZSH]: MimeType.TEXT_SHELL, + [FileType.FISH]: MimeType.TEXT_SHELL, + [FileType.BAT]: MimeType.TEXT_BATCH, + [FileType.CMD]: MimeType.TEXT_BATCH, + [FileType.PS1]: MimeType.TEXT_POWERSHELL, + + // Programming languages + [FileType.PY]: MimeType.TEXT_PYTHON, + [FileType.RB]: MimeType.TEXT_RUBY, + [FileType.PHP]: MimeType.TEXT_PHP, + [FileType.JAVA]: MimeType.TEXT_JAVA, + [FileType.C]: MimeType.TEXT_C, + [FileType.CPP]: MimeType.TEXT_CPP, + [FileType.CC]: MimeType.TEXT_CPP, + [FileType.CXX]: MimeType.TEXT_CPP, + [FileType.H]: MimeType.TEXT_C, + [FileType.HPP]: MimeType.TEXT_CPP, + [FileType.HXX]: MimeType.TEXT_CPP, + [FileType.CS]: MimeType.TEXT_CSHARP, + [FileType.GO]: MimeType.TEXT_GO, + [FileType.RS]: MimeType.TEXT_RUST, + [FileType.SWIFT]: MimeType.TEXT_SWIFT, + [FileType.KT]: MimeType.TEXT_KOTLIN, + [FileType.KTS]: MimeType.TEXT_KOTLIN, + [FileType.SCALA]: MimeType.TEXT_SCALA, + [FileType.M]: MimeType.TEXT_PLAIN, + [FileType.R]: MimeType.TEXT_R, + [FileType.R_UPPER]: MimeType.TEXT_R, + [FileType.JL]: MimeType.TEXT_JULIA, + [FileType.LUA]: MimeType.TEXT_LUA, + [FileType.PL]: MimeType.TEXT_PERL, + [FileType.PM]: MimeType.TEXT_PERL, + [FileType.DART]: MimeType.TEXT_DART, + + // Markup & Documentation + [FileType.TEX]: MimeType.APPLICATION_TEX, + [FileType.LATEX]: MimeType.APPLICATION_LATEX, + [FileType.RST]: MimeType.TEXT_RST, + [FileType.ADOC]: MimeType.TEXT_ASCIIDOC, + [FileType.ASCIIDOC]: MimeType.TEXT_ASCIIDOC, + [FileType.ORG]: MimeType.TEXT_ORG, + [FileType.TEXTILE]: MimeType.TEXT_TEXTILE, + [FileType.RTF]: MimeType.APPLICATION_RTF, + + // Configuration files + [FileType.INI]: MimeType.TEXT_PLAIN, + [FileType.CFG]: MimeType.TEXT_PLAIN, + [FileType.CONF]: MimeType.TEXT_PLAIN, + [FileType.ENV]: MimeType.TEXT_PLAIN, + [FileType.PROPERTIES]: MimeType.TEXT_PLAIN, + [FileType.GITIGNORE]: MimeType.TEXT_PLAIN, + [FileType.GITATTRIBUTES]: MimeType.TEXT_PLAIN, + [FileType.EDITORCONFIG]: MimeType.TEXT_PLAIN, + [FileType.PRETTIERRC]: MimeType.TEXT_PLAIN, + [FileType.ESLINTRC]: MimeType.TEXT_PLAIN, + [FileType.BABELRC]: MimeType.TEXT_PLAIN, + [FileType.NPMRC]: MimeType.TEXT_PLAIN, + [FileType.YARNRC]: MimeType.TEXT_PLAIN, + [FileType.DOCKERFILE]: MimeType.APPLICATION_DOCKERFILE, + + // Query languages + [FileType.SQL]: MimeType.TEXT_SQL, + [FileType.GRAPHQL]: MimeType.TEXT_GRAPHQL, + [FileType.GQL]: MimeType.TEXT_GRAPHQL, + + // Build & Development + [FileType.MAKEFILE]: MimeType.APPLICATION_MAKEFILE, + [FileType.MK]: MimeType.APPLICATION_MAKEFILE, + [FileType.GRADLE]: MimeType.APPLICATION_GRADLE, + [FileType.DIFF]: MimeType.TEXT_PLAIN, + [FileType.PATCH]: MimeType.TEXT_PLAIN, + + // Logs + [FileType.LOG]: MimeType.TEXT_PLAIN, + + [FileType.UNKNOWN]: MimeType.UNKNOWN +}; diff --git a/Helpers/Helpers.ts b/Helpers/Helpers.ts index cecd9cc..a6ae347 100644 --- a/Helpers/Helpers.ts +++ b/Helpers/Helpers.ts @@ -1,4 +1,5 @@ import type VaultkeeperAIPlugin from "main"; +import * as path from "path-browserify"; export function openPluginSettings(plugin: VaultkeeperAIPlugin) { if (!("setting" in plugin.app) || typeof plugin.app.setting !== "object" || plugin.app.setting === null) { @@ -39,6 +40,10 @@ export function shuffleArray(array: T[]): T[] { return shuffled; } +export function pathExtname(filePath: string) { + return path.extname(filePath).substring(1).toLocaleLowerCase(); +} + export async function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } \ No newline at end of file diff --git a/Services/AIFunctionService.ts b/Services/AIFunctionService.ts index bd9e961..b37b46a 100644 --- a/Services/AIFunctionService.ts +++ b/Services/AIFunctionService.ts @@ -8,7 +8,7 @@ import type { ISearchMatch } from "../Helpers/SearchTypes"; import { AbortService } from "./AbortService"; import { normalizePath, TAbstractFile, TFile } from "obsidian"; import { Exception } from "Helpers/Exception"; -import * as path from "path-browserify"; +import { pathExtname } from "Helpers/Helpers"; import { SearchVaultFilesArgsSchema, ReadVaultFilesArgsSchema, @@ -162,7 +162,7 @@ export class AIFunctionService { return { path: filePath, error: result.message } } return { - type: path.extname(filePath).substring(1).toLocaleLowerCase(), + type: pathExtname(filePath), path: filePath, contents: result } diff --git a/Services/ChatService.ts b/Services/ChatService.ts index 9216f81..4a2a6e0 100644 --- a/Services/ChatService.ts +++ b/Services/ChatService.ts @@ -15,6 +15,7 @@ import { Event } from "Enums/Event"; import { AbortService } from "./AbortService"; import { Exception } from "Helpers/Exception"; import { Copy } from "Enums/Copy"; +import type { Attachment } from "Conversations/Attachment"; export interface IChatServiceCallbacks { onSubmit: () => void; @@ -50,7 +51,7 @@ export class ChatService { this.ai = Resolve(Services.IAIClass); } - public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, formattedRequest: string, callbacks: IChatServiceCallbacks) { + public async submit(conversation: Conversation, allowDestructiveActions: boolean, userRequest: string, formattedRequest: string, attachments: Attachment[], callbacks: IChatServiceCallbacks) { if (!await this.semaphore.wait()) { return; } @@ -65,6 +66,8 @@ export class ChatService { this.abortService.initialiseAbortController(); await this.abortService.abortableOperation(async () => { + const firstMessage = conversation.contents.length === 0; + conversation.contents.push(new ConversationContent({ role: Role.User, content: formattedRequest, @@ -72,10 +75,19 @@ export class ChatService { })); await this.saveConversation(conversation); + if (attachments.length > 0) { + // Add any attachments that came from paste / drop + conversation.contents.push(new ConversationContent({ + role: Role.User, + attachments: attachments, + shouldDisplayContent: false + })); + } + callbacks.onSubmit(); callbacks.onStreamingUpdate(null); - if (conversation.contents.length === 1) { + if (firstMessage) { this.onNameChanged?.(conversation.title); // on change for initial conversation name await this.namingService.requestName(conversation, formattedRequest, this.onNameChanged); } diff --git a/Services/FileSystemService.ts b/Services/FileSystemService.ts index 23fa92c..cef8ef4 100644 --- a/Services/FileSystemService.ts +++ b/Services/FileSystemService.ts @@ -28,6 +28,21 @@ export class FileSystemService { return Exception.new(`Path is a folder, not a file: ${filePath}`); } + public async readBinaryFile(filePath: string, allowAccessToPluginRoot: boolean = false): Promise { + const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); + if (file == null) { + return Exception.new(`File does not exist: ${filePath}`); + } + if (file instanceof TFile) { + const arrayBuffer = await this.vaultService.readBinaryData(file, allowAccessToPluginRoot); + if (!arrayBuffer) { + return Exception.new(`Failed to read binary dta for: ${filePath}`); + } + return arrayBuffer; + } + return Exception.new(`Path is a folder, not a file: ${filePath}`); + } + public async writeFile(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { const file: TAbstractFile | null = this.vaultService.getAbstractFileByPath(filePath, allowAccessToPluginRoot); if (file == null || !(file instanceof TFile)) { diff --git a/Services/InputService.ts b/Services/InputService.ts index dd92e94..fa75873 100644 --- a/Services/InputService.ts +++ b/Services/InputService.ts @@ -1,13 +1,85 @@ import { Exception } from "Helpers/Exception"; import { isSearchTriggerElement } from "../Enums/SearchTrigger"; +import { Attachment } from "Conversations/Attachment"; +import { arrayBufferToBase64 } from "obsidian"; +import { FileTypeToMimeType } from "Enums/MimeType"; +import * as path from "path-browserify"; +import { pathExtname } from "Helpers/Helpers"; +import { FileType, toFileType } from "Enums/FileType"; +import type { FileSystemService } from "./FileSystemService"; +import { Resolve } from "./DependencyService"; +import { Services } from "./Services"; export class InputService { - public getPlainTextFromClipboard(clipboardData: DataTransfer | null): string { - if (!clipboardData) { + private readonly fileSystemService: FileSystemService; + + public constructor() { + this.fileSystemService = Resolve(Services.FileSystemService); + } + + public getTextFromDataTransfer(dataTransfer: DataTransfer | null): string { + if (!dataTransfer) { return ""; } - return clipboardData.getData("text/plain") || ""; + return dataTransfer.getData("text/plain") || ""; + } + + public async getFilesFromDataTransfer(dataTransfer: DataTransfer | null): Promise { + const attachments: Attachment[] = []; + + if (!dataTransfer) { + return attachments; + } + + // files from external source (dragged from outside Obsidian) + const files = dataTransfer.files; + if (files) { + for (let i = 0; i < files.length; i++) { + const file = files[i]; + attachments.push(new Attachment( + file.name, + FileTypeToMimeType[toFileType(pathExtname(file.name))], + arrayBufferToBase64(await file.arrayBuffer()) + )); + } + } + + const uriList = dataTransfer.getData("text/uri-list"); + const uris = uriList.split("\n").map(uri => uri.trim()) + .filter(uri => uri.length > 0 && !uri.startsWith("#")); + + for (const uri of uris) { + try { + const url = new URL(uri); + const fileParam = url.searchParams.get("file"); + + if (fileParam) { + let filePath = decodeURIComponent(fileParam); + + let extension = pathExtname(filePath); + // Obsidian doesn't include extension for markdown files + if (extension.trim() === "") { + extension = FileType.MD; + filePath = `${filePath}.${extension}`; + } + + const arrayBuffer = await this.fileSystemService.readBinaryFile(filePath); + + if (arrayBuffer instanceof ArrayBuffer) { + attachments.push(new Attachment( + path.basename(filePath), + FileTypeToMimeType[toFileType(extension)], + arrayBufferToBase64(arrayBuffer) + )); + } + } + } catch (error) { + Exception.log(error); + } + } + + return attachments; } public sanitizeToPlainText(element: HTMLElement) { diff --git a/Services/VaultService.ts b/Services/VaultService.ts index f414855..b8da6d3 100644 --- a/Services/VaultService.ts +++ b/Services/VaultService.ts @@ -3,7 +3,7 @@ import { Resolve } from "./DependencyService"; import { Services } from "./Services"; import type VaultkeeperAIPlugin from "main"; import { Path } from "Enums/Path"; -import { randomSample, shuffleArray } from "Helpers/Helpers"; +import { pathExtname, randomSample, shuffleArray } from "Helpers/Helpers"; import { StringTools } from "Helpers/StringTools"; import type { IPageText, ISearchMatch, ISearchSnippet } from "../Helpers/SearchTypes"; import type { SanitiserService } from "./SanitiserService"; @@ -87,13 +87,24 @@ export class VaultService { } if (isBinaryFile(file.extension.toLowerCase())) { - const arrayBuffer = await this.vault.readBinary(file); - return arrayBufferToBase64(arrayBuffer); + const arrayBuffer = await this.readBinaryData(file, allowAccessToPluginRoot); + if (arrayBuffer) { + return arrayBufferToBase64(arrayBuffer); + } } return await this.vault.read(file); } + public async readBinaryData(file: TFile, allowAccessToPluginRoot: boolean = false): Promise { + const filePath = this.sanitiserService.sanitize(file.path); + if (this.isExclusion(filePath, allowAccessToPluginRoot)) { + Exception.log(`Plugin attempted to read a file that is in the exclusions list: ${filePath}`); + return null; + } + return await this.vault.readBinary(file); + } + public async create(filePath: string, content: string, allowAccessToPluginRoot: boolean = false, requiresConfirmation: boolean = true): Promise { filePath = this.sanitiserService.sanitize(filePath); if (this.isExclusion(filePath, allowAccessToPluginRoot)) { @@ -101,7 +112,7 @@ export class VaultService { return Exception.new(`Failed to create file, permission denied: ${filePath}`); } - if (path.extname(filePath) === "pdf") { + if (isFileType(pathExtname(filePath), FileType.PDF)) { return Exception.new("Creating PDF files is not supported"); } @@ -142,7 +153,7 @@ export class VaultService { return Exception.new(`File does not exist: ${filePath}`); } - if (isFileType(path.extname(filePath), FileType.PDF)) { + if (isFileType(pathExtname(filePath), FileType.PDF)) { return Exception.new("Creating PDF files is not supported"); } @@ -179,7 +190,7 @@ export class VaultService { return currentContent; } - if (isFileType(path.extname(filePath), FileType.PDF)) { + if (isFileType(pathExtname(filePath), FileType.PDF)) { await this.fileManager.trashFile(file); } diff --git a/Styles/custom_styles.css b/Styles/custom_styles.css index 3eb0c19..b05e2dd 100644 --- a/Styles/custom_styles.css +++ b/Styles/custom_styles.css @@ -61,6 +61,13 @@ /* CSS Variables for Common Components */ /* ============================== */ +.transparent-button { + border: none !important; + outline: none !important; + box-shadow: none !important; + background-color: transparent !important; +} + .search-trigger { color: var(--interactive-accent); cursor: default; diff --git a/__tests__/AIClasses/BaseAIClass.test.ts b/__tests__/AIClasses/BaseAIClass.test.ts index 8c2ad57..15d5ab3 100644 --- a/__tests__/AIClasses/BaseAIClass.test.ts +++ b/__tests__/AIClasses/BaseAIClass.test.ts @@ -6,9 +6,6 @@ import { ConversationContent } from '../../Conversations/ConversationContent'; import { Role } from '../../Enums/Role'; import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService'; import { Services } from '../../Services/Services'; -import { StreamingService } from '../../Services/StreamingService'; -import { SettingsService } from '../../Services/SettingsService'; -import { AIFunctionDefinitions } from '../../AIClasses/FunctionDefinitions/AIFunctionDefinitions'; import { AbortService } from '../../Services/AbortService'; import { AIProvider } from '../../Enums/ApiProvider'; diff --git a/__tests__/Conversations/Conversation.test.ts b/__tests__/Conversations/Conversation.test.ts index e6fa7ea..6b71f33 100644 --- a/__tests__/Conversations/Conversation.test.ts +++ b/__tests__/Conversations/Conversation.test.ts @@ -314,7 +314,6 @@ describe('Conversation', () => { const mostRecent = conversation.contents[conversation.contents.length - 1]; if (mostRecent) { mostRecent.functionCall = 'test'; - mostRecent.isFunctionCall = true; } }).not.toThrow(); expect(conversation.contents).toHaveLength(0);