mirror of
https://github.com/andy-stack/vaultkeeper-ai.git
synced 2026-07-22 06:42:03 +00:00
Switch PDF extraction to Obsidian's bundled PDF.js via loadPdfJs() instead of unpdf. Replace officeparser with mammoth for DOCX and custom fflate-based ZIP parsers for PPTX/XLSX/ODF formats. Eliminates heavy dependencies, removes all dynamic eval and .wasm references, and simplifies the esbuild plugin configuration.
445 lines
No EOL
16 KiB
TypeScript
445 lines
No EOL
16 KiB
TypeScript
import { Exception } from "Helpers/Exception";
|
|
import { isSearchTriggerElement } from "../Enums/SearchTrigger";
|
|
import { Attachment } from "Conversations/Attachment";
|
|
import { arrayBufferToBase64, Notice } from "obsidian";
|
|
import { FileTypeToMimeType } from "Enums/FileTypeMimeTypeMapping";
|
|
import * as path from "path-browserify";
|
|
import { pathExtname } from "Helpers/Helpers";
|
|
import { FileType, isDocumentFile, toFileType } from "Enums/FileType";
|
|
import type { FileSystemService } from "./FileSystemService";
|
|
import { Resolve } from "./DependencyService";
|
|
import { Services } from "./Services";
|
|
import { readDocument } from "Helpers/DocumentHelper";
|
|
import { MimeType } from "Enums/MimeType";
|
|
import { StringTools } from "Helpers/StringTools";
|
|
|
|
export class InputService {
|
|
|
|
private readonly fileSystemService: FileSystemService;
|
|
|
|
public constructor() {
|
|
this.fileSystemService = Resolve<FileSystemService>(Services.FileSystemService);
|
|
}
|
|
|
|
public getTextFromDataTransfer(dataTransfer: DataTransfer | null): string {
|
|
if (!dataTransfer) {
|
|
return "";
|
|
}
|
|
return dataTransfer.getData("text/plain") || "";
|
|
}
|
|
|
|
public async getFilesFromDataTransfer(dataTransfer: DataTransfer | null): Promise<Attachment[]> {
|
|
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];
|
|
const fileType = toFileType(pathExtname(file.name))
|
|
|
|
if (fileType === FileType.UNKNOWN) {
|
|
new Notice(`Unsupported file '${file.name}'`);
|
|
continue;
|
|
}
|
|
|
|
if (isDocumentFile(fileType)) {
|
|
const content = await readDocument(await file.arrayBuffer(), fileType);
|
|
attachments.push(new Attachment(
|
|
file.name,
|
|
MimeType.TEXT_PLAIN,
|
|
StringTools.toBase64(content[0].text)
|
|
));
|
|
} else {
|
|
attachments.push(new Attachment(
|
|
file.name,
|
|
FileTypeToMimeType[fileType],
|
|
arrayBufferToBase64(await file.arrayBuffer())
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
// get text as sometimes uri's come through as plain text
|
|
const textUriList = this.getTextFromDataTransfer(dataTransfer);
|
|
const uriList = dataTransfer.getData("text/uri-list");
|
|
|
|
const allUris = `${uriList}\n${textUriList}`.split("\n").map(uri => uri.trim())
|
|
.filter(uri => uri.length > 0 && !uri.startsWith("#"));
|
|
|
|
const uris = this.removeDuplicateUris(allUris);
|
|
|
|
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.warn(error); // Just a warning as some pasted content seem to give nonsense URIs
|
|
}
|
|
}
|
|
|
|
return attachments;
|
|
}
|
|
|
|
public sanitizeToPlainText(element: HTMLElement) {
|
|
const plainText = element.textContent || "";
|
|
const cursorPos = this.getCursorPosition(element);
|
|
|
|
element.textContent = plainText;
|
|
|
|
// Restore cursor position after sanitization
|
|
this.setCursorPosition(element, cursorPos);
|
|
}
|
|
|
|
public hasUnauthorizedHTML(element: HTMLElement): boolean {
|
|
const checkNode = (node: Node): boolean => {
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
return false;
|
|
}
|
|
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
const el = node as HTMLElement;
|
|
|
|
if (isSearchTriggerElement(node)) {
|
|
return false;
|
|
}
|
|
|
|
// Allow BR tags (browsers auto-insert these in contentEditable)
|
|
if (el.tagName === "BR") {
|
|
return false;
|
|
}
|
|
|
|
// Allow DIV tags (browsers wrap content in divs) - but check their children recursively
|
|
if (el.tagName === "DIV") {
|
|
// Recursively check all children of the div
|
|
return Array.from(el.childNodes).some(checkNode);
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
return Array.from(element.childNodes).some(checkNode);
|
|
}
|
|
|
|
public isInSearchZone(currentPosition: number, triggerPosition: number): boolean {
|
|
return currentPosition > triggerPosition;
|
|
}
|
|
|
|
public isPrintableKey(key: string, ctrlKey: boolean = false, metaKey: boolean = false): boolean {
|
|
// Control or meta keys are not printable
|
|
if (ctrlKey || metaKey) {
|
|
return false;
|
|
}
|
|
|
|
// Single character keys are printable
|
|
if (key.length === 1) {
|
|
return true;
|
|
}
|
|
|
|
// Special printable keys
|
|
return key === "Enter" || key === "Tab";
|
|
}
|
|
|
|
public getCursorPosition(element: HTMLElement): number {
|
|
const selection = activeWindow.window.getSelection() || new Selection();
|
|
|
|
if (selection.rangeCount === 0) {
|
|
return -1;
|
|
}
|
|
|
|
const range = selection.getRangeAt(0);
|
|
const preSelectionRange = range.cloneRange();
|
|
preSelectionRange.selectNodeContents(element);
|
|
preSelectionRange.setEnd(range.startContainer, range.startOffset);
|
|
|
|
return preSelectionRange.toString().length;
|
|
}
|
|
|
|
public setCursorPosition(element: HTMLElement, position: number, fromEnd: boolean = false) {
|
|
// Ensure element is focusable
|
|
if (!element.isContentEditable) {
|
|
Exception.warn("Element must be contenteditable");
|
|
return false;
|
|
}
|
|
|
|
// Get text content for position calculation
|
|
const textContent = element.textContent || element.innerText || "";
|
|
const maxPosition = textContent.length;
|
|
|
|
// Calculate actual position
|
|
let targetPosition;
|
|
if (fromEnd) {
|
|
targetPosition = Math.max(0, maxPosition - position);
|
|
} else {
|
|
targetPosition = Math.min(position, maxPosition);
|
|
}
|
|
|
|
try {
|
|
// Create range and selection
|
|
const range = activeWindow.document.createRange();
|
|
const selection = activeWindow.getSelection() ?? new Selection();
|
|
|
|
// Find the text node and position
|
|
const result = this.findTextNodeAndOffset(element, targetPosition);
|
|
|
|
if (result.node) {
|
|
range.setStart(result.node, result.offset);
|
|
range.setEnd(result.node, result.offset);
|
|
|
|
// Clear existing selection and apply new range
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
// Focus the element
|
|
element.focus();
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
|
|
} catch (error) {
|
|
Exception.log(error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
|
|
public insertTextAtCursor(text: string, element?: HTMLElement) {
|
|
if (element && !element.isContentEditable) {
|
|
Exception.warn("Element must be contenteditable");
|
|
return;
|
|
}
|
|
|
|
const selection = activeWindow.getSelection();
|
|
if (!selection || selection.rangeCount === 0) {
|
|
return;
|
|
}
|
|
|
|
const range = selection.getRangeAt(0);
|
|
range.deleteContents();
|
|
|
|
const textNode = activeDocument.createTextNode(text);
|
|
range.insertNode(textNode);
|
|
|
|
range.setStartAfter(textNode);
|
|
range.setEndAfter(textNode);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
|
|
public insertElementAtCursor(node: Node, element?: HTMLElement) {
|
|
if (element && !element.isContentEditable) {
|
|
Exception.warn("Element must be contenteditable");
|
|
return;
|
|
}
|
|
|
|
const selection = activeWindow.getSelection();
|
|
if (!selection || selection.rangeCount === 0) {
|
|
return;
|
|
}
|
|
|
|
const range = selection.getRangeAt(0);
|
|
range.deleteContents();
|
|
|
|
// Insert the node
|
|
range.insertNode(node);
|
|
|
|
// Move cursor to end of inserted element
|
|
range.setStartAfter(node);
|
|
range.setEndAfter(node);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
|
|
public deleteTextRange(startPos: number, endPos: number, element: HTMLElement) {
|
|
if (!element.isContentEditable) {
|
|
Exception.warn("Element must be contenteditable");
|
|
return;
|
|
}
|
|
|
|
const selection = activeWindow.getSelection();
|
|
if (!selection) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const range = activeDocument.createRange();
|
|
|
|
// Find the text nodes and offsets for start and end positions
|
|
const startResult = this.findTextNodeAndOffset(element, startPos);
|
|
const endResult = this.findTextNodeAndOffset(element, endPos);
|
|
|
|
if (!startResult.node || !endResult.node) {
|
|
Exception.warn("Could not find text nodes for range deletion");
|
|
return;
|
|
}
|
|
|
|
// Set the range to span from start to end position
|
|
range.setStart(startResult.node, startResult.offset);
|
|
range.setEnd(endResult.node, endResult.offset);
|
|
|
|
// Delete the range contents
|
|
range.deleteContents();
|
|
|
|
// Set cursor to where deletion occurred
|
|
this.setCursorPosition(element, startPos);
|
|
|
|
// Validate and fix cursor position if it ended up in a non-editable element
|
|
this.ensureCursorNotInNonEditableElement(element);
|
|
|
|
} catch (error) {
|
|
Exception.log(error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ensures the cursor is not positioned inside a contentEditable="false" element.
|
|
* If it is, repositions the cursor to a valid location.
|
|
*/
|
|
private ensureCursorNotInNonEditableElement(element: HTMLElement) {
|
|
const selection = activeWindow.getSelection();
|
|
if (!selection || selection.rangeCount === 0) {
|
|
return;
|
|
}
|
|
|
|
const range = selection.getRangeAt(0);
|
|
let node: Node | null = range.startContainer;
|
|
|
|
// Walk up the tree to check if we're inside a non-editable element
|
|
while (node && node !== element) {
|
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
const elem = node as HTMLElement;
|
|
if (elem.contentEditable === "false" || elem.getAttribute("contenteditable") === "false") {
|
|
// Found a non-editable ancestor - reposition cursor after it
|
|
this.positionCursorAfterElement(elem, element);
|
|
return;
|
|
}
|
|
}
|
|
node = node.parentNode;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Positions the cursor immediately after the given element.
|
|
*/
|
|
private positionCursorAfterElement(targetElement: HTMLElement, container: HTMLElement) {
|
|
const selection = activeWindow.getSelection();
|
|
if (!selection) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const range = activeDocument.createRange();
|
|
|
|
// Try to position cursor in the next text node or after the element
|
|
const nextSibling = targetElement.nextSibling;
|
|
if (nextSibling) {
|
|
if (nextSibling.nodeType === Node.TEXT_NODE) {
|
|
range.setStart(nextSibling, 0);
|
|
range.setEnd(nextSibling, 0);
|
|
} else {
|
|
range.setStartBefore(nextSibling);
|
|
range.setEndBefore(nextSibling);
|
|
}
|
|
} else {
|
|
// No next sibling, position after the element
|
|
range.setStartAfter(targetElement);
|
|
range.setEndAfter(targetElement);
|
|
}
|
|
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
container.focus();
|
|
} catch (error) {
|
|
Exception.log(error);
|
|
}
|
|
}
|
|
|
|
private findTextNodeAndOffset(element: HTMLElement, targetPosition: number): { node: Node | null; offset: number } {
|
|
let currentPosition = 0;
|
|
|
|
function traverse(node: Node): { node: Node | null; offset: number } {
|
|
// If it"s a text node, check if target position falls within it
|
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
const textLength = node.textContent?.length || 0;
|
|
|
|
if (currentPosition + textLength >= targetPosition) {
|
|
// Found the target text node
|
|
const offset = targetPosition - currentPosition;
|
|
return { node: node, offset: offset };
|
|
}
|
|
|
|
currentPosition += textLength;
|
|
} else {
|
|
// Recursively check child nodes
|
|
for (let index = 0; index < node.childNodes.length; index++) {
|
|
const result = traverse(node.childNodes[index]);
|
|
if (result.node) {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { node: null, offset: 0 };
|
|
}
|
|
|
|
return traverse(element);
|
|
}
|
|
|
|
private removeDuplicateUris(uris: string[]): string[] {
|
|
const seenFiles = new Set<string>();
|
|
const uniqueUris: string[] = [];
|
|
|
|
for (const uri of uris) {
|
|
try {
|
|
const url = new URL(uri);
|
|
const fileParam = url.searchParams.get("file");
|
|
|
|
if (fileParam) {
|
|
// Normalize by decoding the file parameter to handle different URL encodings
|
|
const normalizedFile = decodeURIComponent(fileParam);
|
|
|
|
if (!seenFiles.has(normalizedFile)) {
|
|
seenFiles.add(normalizedFile);
|
|
uniqueUris.push(uri);
|
|
}
|
|
} else {
|
|
// If no file parameter, include the URI anyway
|
|
uniqueUris.push(uri);
|
|
}
|
|
} catch {
|
|
// If URL parsing fails, include the URI anyway
|
|
uniqueUris.push(uri);
|
|
}
|
|
}
|
|
|
|
return uniqueUris;
|
|
}
|
|
|
|
} |