mirror of
https://github.com/travisvn/obsidian-vision-recall.git
synced 2026-07-22 12:00:25 +00:00
Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b99aafa81 | ||
|
|
ea199c4d50 | ||
|
|
50a079968a | ||
|
|
773cb610e6 | ||
|
|
317d3c2f6f | ||
|
|
a183e1e12c | ||
|
|
e88f1aa246 | ||
|
|
9d8a2c6cfa | ||
|
|
5b8c5f9012 | ||
|
|
b5b86dc5ac | ||
|
|
63a28f1536 | ||
|
|
2ef08c3dd5 |
30 changed files with 1331 additions and 190 deletions
14
README.md
14
README.md
|
|
@ -3,14 +3,16 @@
|
|||
<p align="center">
|
||||
<a href="https://github.com/travisvn/obsidian-vision-recall">
|
||||
<img src="https://img.shields.io/github/stars/travisvn/obsidian-vision-recall?style=social" alt="GitHub stars"></a>
|
||||
<a href="https://tts.travisvn.com/obsidian" target="_blank">
|
||||
<a href="https://visionrecall.com/obsidian-plugin" target="_blank">
|
||||
<img src="https://img.shields.io/badge/dynamic/json?logo=obsidian&color=%23483699&label=downloads&query=%24%5B%27vision-recall%27%5D.downloads&url=https%3A%2F%2Fraw.githubusercontent.com%2Fobsidianmd%2Fobsidian-releases%2Fmaster%2Fcommunity-plugin-stats.json" alt="Obsidian downloads"></a>
|
||||
<a href="https://github.com/travisvn/obsidian-vision-recall/releases">
|
||||
<img src="https://img.shields.io/github/v/release/travisvn/obsidian-vision-recall" alt="GitHub release"></a>
|
||||
<a href="https://github.com/travisvn/obsidian-vision-recall/issues">
|
||||
<img src="https://img.shields.io/github/issues/travisvn/obsidian-vision-recall" alt="GitHub issues"></a>
|
||||
<img src="https://img.shields.io/github/last-commit/travisvn/obsidian-vision-recall?color=red" alt="GitHub last commit">
|
||||
<img src="https://hits.seeyoufarm.com/api/count/incr/badge.svg?url=https%3A%2F%2Fgithub.com%2Ftravisvn%2Fobsidian-vision-recall&count_bg=%2379C83D&title_bg=%23555555&icon=&icon_color=%23E7E7E7&title=hits&edge_flat=false" alt="Hits">
|
||||
<a href="https://visionrecall.com/discord">
|
||||
<img src="https://img.shields.io/badge/Discord-Vision_Recall-blue?logo=discord&logoColor=white" alt="Discord">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Screenshot anything you want to recall later. Let AI handle the rest.</h1>
|
||||
|
|
@ -50,6 +52,8 @@
|
|||
|
||||
## Installation
|
||||
|
||||
[Install Obsidian plugin](https://visionrecall.com/obsidian-plugin)
|
||||
|
||||
1. Open Obsidian
|
||||
2. Go to **Settings** → **Community Plugins**
|
||||
3. Search for **Vision Recall**
|
||||
|
|
@ -74,3 +78,9 @@
|
|||
- **Managing Processing Queue:** Use the Processing Queue modal (accessible from the status bar button or commands) to monitor and control screenshot processing.
|
||||
|
||||

|
||||
|
||||
### Troubleshooting
|
||||
|
||||
Visit the [wiki](https://github.com/travisvn/obsidian-vision-recall/wiki)
|
||||
|
||||
If you still need help, [join our new Discord](https://visionrecall.com/discord)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "vision-recall",
|
||||
"name": "Vision Recall",
|
||||
"version": "1.0.10",
|
||||
"version": "1.2.0",
|
||||
"minAppVersion": "1.8.3",
|
||||
"description": "Transform screenshots into searchable notes using AI vision and text analysis.",
|
||||
"author": "Travis Van Nimwegen",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "obsidian-vision-recall",
|
||||
"version": "1.0.10",
|
||||
"version": "1.2.0",
|
||||
"description": "Transform screenshots into searchable Obsidian notes using AI vision and text analysis.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { useObsidianApp, usePlugin } from '@/context';
|
||||
import { FileUploadModal } from '@/components/modals/FileUploadModal';
|
||||
import { FolderSync, Info, LoaderPinwheel, Plus, RefreshCcw, Settings2, LayoutGrid, List, Bug, Maximize2, Hash, PencilRuler, FolderInput } from 'lucide-react';
|
||||
import { FolderSync, Info, LoaderPinwheel, Plus, RefreshCcw, Settings2, LayoutGrid, List, Bug, Maximize2, Hash, PencilRuler, FolderInput, HelpCircle } from 'lucide-react';
|
||||
import { Notice } from 'obsidian';
|
||||
import React from 'react';
|
||||
import { DocViewerModal } from '@/components/modals/DocViewerModal';
|
||||
import { ConfigModal } from '@/components/modals/ConfigModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDataContext } from '@/data/DataContext';
|
||||
import { TestConfigModal } from './modals/TestConfigModal';
|
||||
import { TestSetupModal } from './modals/TestSetupModal';
|
||||
import { ProcessingQueueModal } from './modals/ProcessingQueueModal';
|
||||
import { useQueueStore } from '@/stores/queueStore';
|
||||
import { DebugOperationsModal } from './modals/DebugOperationsModal';
|
||||
import { HelpModal } from './modals/HelpModal';
|
||||
|
||||
interface MainViewHeaderProps {
|
||||
metadata: any[];
|
||||
|
|
@ -47,7 +48,18 @@ export const MainViewHeader = ({ metadata, refreshMetadata, viewMode = 'list', o
|
|||
return (
|
||||
<>
|
||||
<div className='@container/header flex flex-row justify-between items-center'>
|
||||
<h2 className='m-0'>Vision Recall</h2>
|
||||
<a
|
||||
href={'https://github.com/travisvn/obsidian-vision-recall'}
|
||||
target='_blank'
|
||||
className='cursor-pointer text-text-normal no-underline'
|
||||
style={{ textDecoration: 'none' }}
|
||||
aria-label='Star this plugin on GitHub'
|
||||
>
|
||||
<h2
|
||||
className='m-0 text-text-normal no-underline'
|
||||
style={{ textDecoration: 'none' }}
|
||||
>Vision Recall</h2>
|
||||
</a>
|
||||
|
||||
{onViewModeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -105,21 +117,20 @@ export const MainViewHeader = ({ metadata, refreshMetadata, viewMode = 'list', o
|
|||
Process intake
|
||||
</div>
|
||||
</button>
|
||||
{plugin.settings.intakeFromVaultFolder && (
|
||||
<button
|
||||
className='cursor-pointer flex flex-row items-center gap-2'
|
||||
aria-label='Intake from the vault root folder'
|
||||
onClick={async () => {
|
||||
await plugin.screenshotProcessor.processVaultRootFolder();
|
||||
await refreshMetadata();
|
||||
}}
|
||||
>
|
||||
<FolderInput className='w-4 h-4' />
|
||||
<div className='hidden @3xl/header:block'>
|
||||
Intake from vault root
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className='cursor-pointer flex flex-row items-center gap-2'
|
||||
aria-label='Select images from a vault folder'
|
||||
onClick={() => {
|
||||
plugin.helperService.selectImages(app.vault.getRoot());
|
||||
}}
|
||||
>
|
||||
<FolderInput className='w-4 h-4' />
|
||||
<div className='hidden @4xl/header:block'>
|
||||
Select images from vault
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
<button
|
||||
className='cursor-pointer flex flex-row items-center gap-2'
|
||||
aria-label='Refresh the screenshots'
|
||||
|
|
@ -129,11 +140,9 @@ export const MainViewHeader = ({ metadata, refreshMetadata, viewMode = 'list', o
|
|||
}}
|
||||
>
|
||||
<RefreshCcw className='w-4 h-4' />
|
||||
{!plugin.settings.intakeFromVaultFolder && (
|
||||
<div className='hidden @3xl/header:block'>
|
||||
Refresh
|
||||
</div>
|
||||
)}
|
||||
<div className='hidden @4xl/header:block'>
|
||||
Refresh
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -157,10 +166,10 @@ export const MainViewHeader = ({ metadata, refreshMetadata, viewMode = 'list', o
|
|||
</button>
|
||||
|
||||
<button
|
||||
aria-label='Test config'
|
||||
aria-label='Test setup'
|
||||
className='cursor-pointer flex flex-row items-center gap-2'
|
||||
onClick={() => {
|
||||
new TestConfigModal(
|
||||
new TestSetupModal(
|
||||
app,
|
||||
plugin,
|
||||
dataManager
|
||||
|
|
@ -169,7 +178,23 @@ export const MainViewHeader = ({ metadata, refreshMetadata, viewMode = 'list', o
|
|||
>
|
||||
<Bug className='w-4 h-4' />
|
||||
<div className='hidden @3xl/subheader:block'>
|
||||
Test config
|
||||
Test setup
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
aria-label='Help'
|
||||
className='cursor-pointer flex flex-row items-center gap-2'
|
||||
onClick={() => {
|
||||
new HelpModal(
|
||||
app,
|
||||
plugin
|
||||
).open();
|
||||
}}
|
||||
>
|
||||
<HelpCircle className='w-4 h-4' />
|
||||
<div className='hidden @3xl/subheader:block'>
|
||||
Help
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -239,7 +264,7 @@ export const MainViewHeader = ({ metadata, refreshMetadata, viewMode = 'list', o
|
|||
</div>
|
||||
|
||||
<button
|
||||
aria-label='Advanced settings'
|
||||
aria-label='Advanced configuration'
|
||||
className='cursor-pointer flex flex-row items-center gap-2'
|
||||
onClick={() => {
|
||||
new ConfigModal(app, plugin, plugin.dataManager).open();
|
||||
|
|
@ -247,7 +272,7 @@ export const MainViewHeader = ({ metadata, refreshMetadata, viewMode = 'list', o
|
|||
>
|
||||
<Settings2 className='w-4 h-4' />
|
||||
<div className='hidden @3xl/subheader:block'>
|
||||
Settings
|
||||
Config
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { Modal } from 'obsidian';
|
||||
import { DataManager } from '@/data/DataManager';
|
||||
import { Plugin } from 'obsidian';
|
||||
import { Config, DefaultConfig } from '@/types/config-types';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ConfigModalContent = ({
|
||||
dataManager,
|
||||
|
|
@ -16,11 +17,17 @@ const ConfigModalContent = ({
|
|||
const [config, setConfig] = useState<Config>({});
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const [enableCategoryDetection, setEnableCategoryDetection] = useState(false);
|
||||
|
||||
const promptTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const notesPromptTextareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
const loadConfig = async () => {
|
||||
const currentConfig = dataManager.getConfig();
|
||||
setConfig(currentConfig);
|
||||
setEnableCategoryDetection(currentConfig.enableCategoryDetection);
|
||||
setInitialized(true);
|
||||
};
|
||||
loadConfig();
|
||||
|
|
@ -34,9 +41,26 @@ const ConfigModalContent = ({
|
|||
|
||||
const updateConfig = (updates: Partial<Config>) => {
|
||||
setConfig(prev => ({ ...prev, ...updates }));
|
||||
if (updates.enableCategoryDetection !== undefined) {
|
||||
setEnableCategoryDetection(updates.enableCategoryDetection);
|
||||
}
|
||||
setDirty(true);
|
||||
};
|
||||
|
||||
const resetVisionLLMPrompt = () => {
|
||||
if (promptTextareaRef.current) {
|
||||
promptTextareaRef.current.value = DefaultConfig.visionLLMPrompt;
|
||||
}
|
||||
updateConfig({ visionLLMPrompt: DefaultConfig.visionLLMPrompt });
|
||||
};
|
||||
|
||||
const resetNotesLLMPrompt = () => {
|
||||
if (notesPromptTextareaRef.current) {
|
||||
notesPromptTextareaRef.current.value = DefaultConfig.notesLLMPrompt;
|
||||
}
|
||||
updateConfig({ notesLLMPrompt: DefaultConfig.notesLLMPrompt });
|
||||
};
|
||||
|
||||
if (!initialized) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
|
@ -96,8 +120,85 @@ const ConfigModalContent = ({
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<div className='config-field'>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className='flex flex-row gap-1 w-full items-center'>
|
||||
<label className='text-sm font-bold flex-1'>
|
||||
Vision LLM prompt
|
||||
</label>
|
||||
<button
|
||||
aria-label="Reset vision LLM prompt to default"
|
||||
type="button"
|
||||
className="mod-warning cursor-pointer opacity-50 hover:opacity-100 duration-300 text-xs px-2 py-1"
|
||||
onClick={resetVisionLLMPrompt}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={promptTextareaRef}
|
||||
className='w-full flex-1'
|
||||
defaultValue={config.visionLLMPrompt || DefaultConfig.visionLLMPrompt}
|
||||
onChange={e => updateConfig({ visionLLMPrompt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<div className="config-field">
|
||||
<label
|
||||
className="config-field-checkbox-label"
|
||||
aria-label={`Attempts to categorize screenshots based on vision LLM response. If enabled, the notes LLM prompt will be modified to include the category.`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
defaultChecked={config.enableCategoryDetection || DefaultConfig.enableCategoryDetection}
|
||||
onChange={e => updateConfig({ enableCategoryDetection: e.target.checked })}
|
||||
/>
|
||||
Enable category detection
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
<div className='config-field'>
|
||||
<div className="flex flex-col gap-1 w-full">
|
||||
<div className='flex flex-row gap-1 w-full items-center'>
|
||||
<label
|
||||
className={cn(
|
||||
'text-sm font-bold flex-1',
|
||||
enableCategoryDetection && 'opacity-30 hover:opacity-70 duration-300'
|
||||
)}
|
||||
aria-label={!enableCategoryDetection ? 'Notes LLM prompt' : 'Notes LLM prompt (disabled because category detection is enabled)'}
|
||||
>
|
||||
Notes LLM prompt
|
||||
</label>
|
||||
<button
|
||||
aria-label="Reset notes LLM prompt to default"
|
||||
type="button"
|
||||
className="mod-warning cursor-pointer opacity-30 hover:opacity-70 duration-300 text-xs px-2 py-1"
|
||||
onClick={resetNotesLLMPrompt}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
ref={notesPromptTextareaRef}
|
||||
aria-label={!enableCategoryDetection ? 'Notes LLM prompt' : 'Notes LLM prompt (disabled because category detection is enabled)'}
|
||||
className={cn(
|
||||
'w-full flex-1',
|
||||
enableCategoryDetection && 'opacity-30 hover:opacity-70 duration-300'
|
||||
)}
|
||||
disabled={enableCategoryDetection}
|
||||
defaultValue={config.notesLLMPrompt || DefaultConfig.notesLLMPrompt}
|
||||
onChange={e => updateConfig({ notesLLMPrompt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* <div className="config-field">
|
||||
<label className="config-field-checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
|
@ -106,7 +207,7 @@ const ConfigModalContent = ({
|
|||
/>
|
||||
Default minimized progress display
|
||||
</label>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
|
||||
<div className="config-modal-controls">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import VisionRecallPlugin from '@/main';
|
|||
// import referenceGuide from './reference.md';
|
||||
import ollamaSetup from '@/docs/ollama-setup.md';
|
||||
import referenceGuide from '@/docs/reference.md';
|
||||
import lmStudio from '@/docs/lm-studio.md';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
interface DocViewerFormProps {
|
||||
onClose: () => void;
|
||||
|
|
@ -16,6 +18,7 @@ interface DocViewerFormProps {
|
|||
const DOC_LOCATIONS = {
|
||||
'ollama-setup': ollamaSetup,
|
||||
'reference-guide': referenceGuide,
|
||||
'lm-studio': lmStudio,
|
||||
}
|
||||
|
||||
const DocViewerForm: React.FC<DocViewerFormProps> = ({ onClose, app, plugin }) => {
|
||||
|
|
@ -72,6 +75,15 @@ const DocViewerForm: React.FC<DocViewerFormProps> = ({ onClose, app, plugin }) =
|
|||
>
|
||||
Ollama setup
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='cursor-pointer'
|
||||
onClick={async () => {
|
||||
setSelectedDoc('lm-studio');
|
||||
}}
|
||||
>
|
||||
LM Studio
|
||||
</button>
|
||||
<button
|
||||
type='button'
|
||||
className='cursor-pointer'
|
||||
|
|
@ -81,9 +93,21 @@ const DocViewerForm: React.FC<DocViewerFormProps> = ({ onClose, app, plugin }) =
|
|||
>
|
||||
Reference guide
|
||||
</button>
|
||||
<div className='flex flex-row gap-2 justify-center items-center'>
|
||||
<a
|
||||
href={'https://github.com/travisvn/obsidian-vision-recall/wiki'}
|
||||
target='_blank'
|
||||
className='cursor-pointer inline-flex flex-row items-center gap-2'
|
||||
aria-label={'Open the wiki on GitHub for more information'}
|
||||
>
|
||||
Wiki <ExternalLink className='w-4 h-4' />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
<div ref={containerRef} className="markdown-content" />
|
||||
<div className="max-h-[calc(90vh-200px)] overflow-y-auto">
|
||||
<div ref={containerRef} className="markdown-content" />
|
||||
</div>
|
||||
<div className="button-group">
|
||||
<button type="button" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
|
|
@ -100,7 +124,8 @@ export class DocViewerModal extends Modal {
|
|||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl, titleEl } = this;
|
||||
const { contentEl, titleEl, modalEl } = this;
|
||||
modalEl.addClass('vr-doc-viewer-modal');
|
||||
titleEl.setText('Documentation');
|
||||
const root = createRoot(contentEl);
|
||||
|
||||
|
|
|
|||
78
src/components/modals/HelpModal.tsx
Normal file
78
src/components/modals/HelpModal.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import { App, Modal } from 'obsidian';
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import VisionRecallPlugin from '@/main';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
|
||||
interface HelpModalProps {
|
||||
plugin: VisionRecallPlugin;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const HelpView: React.FC<HelpModalProps> = ({ plugin, onClose }) => {
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4 items-center justify-center vision-recall-styling'>
|
||||
<div className='flex flex-col gap-4 items-center justify-center'>
|
||||
<h3 className='text-lg/0 p-0 m-0'>Need help?</h3>
|
||||
<p>
|
||||
If you need help, please refer to the <a
|
||||
href='https://github.com/travisvn/obsidian-vision-recall/wiki'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='cursor-pointer inline-flex flex-row items-center gap-1'
|
||||
aria-label={'Open the wiki on GitHub for more information'}
|
||||
>
|
||||
wiki <ExternalLink className='w-3 h-3' />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<h3 className='text-lg/0 p-0 m-0'>Troubleshooting, support, & community</h3>
|
||||
|
||||
<div className='flex flex-col gap-4 items-center justify-center'>
|
||||
<a
|
||||
href='https://visionrecall.com/discord'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='cursor-pointer inline-flex flex-row items-center gap-1'
|
||||
aria-label={'Join the Discord server'}
|
||||
>
|
||||
Join the new Discord server <ExternalLink className='w-3 h-3' />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row justify-end items-center w-full">
|
||||
<button type="button" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export class HelpModal extends Modal {
|
||||
private plugin: VisionRecallPlugin;
|
||||
|
||||
constructor(app: App, plugin: VisionRecallPlugin) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl, titleEl } = this;
|
||||
// titleEl.setText('Help');
|
||||
const root = createRoot(contentEl);
|
||||
|
||||
root.render(
|
||||
<HelpView
|
||||
plugin={this.plugin}
|
||||
onClose={() => this.close()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
213
src/components/modals/SelectImagesModal.tsx
Normal file
213
src/components/modals/SelectImagesModal.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import { App, Modal, TFolder, TFile } from 'obsidian';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import VisionRecallPlugin from '@/main';
|
||||
import { DateTime } from 'luxon';
|
||||
import { FolderWithPath } from '@/types/shared-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SelectImagesModalProps {
|
||||
plugin: VisionRecallPlugin;
|
||||
folder: TFolder;
|
||||
onClose: () => void;
|
||||
onSelect: (images: TFile[]) => any;
|
||||
}
|
||||
|
||||
const SelectImagesForm: React.FC<SelectImagesModalProps> = ({ plugin, folder, onClose, onSelect }) => {
|
||||
const [initialized, setInitialized] = useState<boolean>(false);
|
||||
|
||||
const [selectedFolder, setSelectedFolder] = useState<TFolder | null>(null);
|
||||
const [selectedFolderPath, setSelectedFolderPath] = useState<string | null>(folder?.path || null);
|
||||
const [selectedImages, setSelectedImages] = useState<TFile[]>([]);
|
||||
const [folders, setFolders] = useState<FolderWithPath[]>([]);
|
||||
const [foldersLoading, setFoldersLoading] = useState<boolean>(false);
|
||||
|
||||
const [imagesLoading, setImagesLoading] = useState<boolean>(false);
|
||||
const [images, setImages] = useState<TFile[]>([]);
|
||||
|
||||
|
||||
async function fetchImages(fetchFolder: TFolder) {
|
||||
console.log('fetchImages', fetchFolder);
|
||||
const newImages = await plugin.helperService.retrieveImagesFromFolder(fetchFolder, false);
|
||||
setImages(newImages);
|
||||
setImagesLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialized) return;
|
||||
console.log('selectedFolderPath', selectedFolderPath);
|
||||
|
||||
async function fetchImagesInternal(newSelectedFolder: TFolder) {
|
||||
setImagesLoading(true);
|
||||
await fetchImages(newSelectedFolder);
|
||||
}
|
||||
|
||||
const newSelectedFolder = folders.find((folder) => folder.path === selectedFolderPath);
|
||||
setSelectedFolder(newSelectedFolder?.folder || null);
|
||||
|
||||
fetchImagesInternal(newSelectedFolder?.folder || null);
|
||||
}, [selectedFolderPath, initialized]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized) return;
|
||||
|
||||
const fetchFolders = async () => {
|
||||
setFoldersLoading(true);
|
||||
const folders = await plugin.helperService.retrieveAllFoldersWithPaths();
|
||||
setFolders(folders);
|
||||
setFoldersLoading(false);
|
||||
setInitialized(true);
|
||||
}
|
||||
|
||||
fetchFolders();
|
||||
}, [plugin]);
|
||||
|
||||
const handleImageClick = (image: TFile) => {
|
||||
if (selectedImages.includes(image)) {
|
||||
setSelectedImages(selectedImages.filter((img) => img !== image));
|
||||
} else {
|
||||
setSelectedImages([...selectedImages, image]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="vr select-images-modal flex flex-col gap-2">
|
||||
<div className='flex flex-row items-center gap-2 w-full'>
|
||||
<select
|
||||
className='flex-1 w-full cursor-pointer'
|
||||
onChange={async (e) => {
|
||||
setSelectedFolderPath(e.target.value);
|
||||
// await sleep(300);
|
||||
// await fetchImages(selectedFolder);
|
||||
}}
|
||||
>
|
||||
{folders.map((folder: FolderWithPath) => (
|
||||
<option
|
||||
key={folder.path}
|
||||
value={folder.path}
|
||||
// onClick={async () => {
|
||||
// setSelectedFolderPath(folder.path);
|
||||
// await sleep(300);
|
||||
// await fetchImages();
|
||||
// }}
|
||||
>
|
||||
{folder.path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className='text-text-muted'>
|
||||
{folder.name}
|
||||
</div>
|
||||
|
||||
{images.length === 0 ? (
|
||||
<div className='text-text-muted text-center mt-2'>
|
||||
No images found
|
||||
</div>
|
||||
) : (
|
||||
<div className='text-text-muted text-center mt-2'>
|
||||
{selectedImages.length} images selected
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='gallery-grid overflow-y-auto max-h-[calc(90vh-270px)]'>
|
||||
{images.map((image) => (
|
||||
<div key={image.path} className="gallery-item group">
|
||||
<div className="gallery-image-container relative">
|
||||
<img
|
||||
src={plugin.app.vault.adapter.getResourcePath(image.path)}
|
||||
alt={image.name}
|
||||
onClick={() => {
|
||||
handleImageClick(image);
|
||||
}}
|
||||
className={cn('gallery-image cursor-pointer', {
|
||||
'opacity-50': selectedImages.includes(image),
|
||||
})}
|
||||
aria-label={selectedImages.includes(image) ? `Deselect screenshot` : `Select screenshot`}
|
||||
data-tooltip-position="top"
|
||||
/>
|
||||
|
||||
{selectedImages.includes(image) && (
|
||||
<div className="gallery-overlay-content-display">
|
||||
<div className="flex flex-col items-center justify-center pointer-events-auto group-hover:hidden duration-0">
|
||||
Selected
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="gallery-overlay">
|
||||
<div className="flex flex-col items-center justify-center pointer-events-auto">
|
||||
{selectedImages.includes(image) ? 'Deselect' : 'Select'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-4 justify-end items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className='cursor-pointer'
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
className='cursor-pointer'
|
||||
type="submit"
|
||||
onClick={() => {
|
||||
onSelect(selectedImages);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Select and process
|
||||
</button>
|
||||
</div>
|
||||
</div >
|
||||
);
|
||||
};
|
||||
|
||||
export class SelectImagesModal extends Modal {
|
||||
private folder: TFolder;
|
||||
private plugin: VisionRecallPlugin;
|
||||
private onSelect: (images: TFile[]) => any;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
plugin: VisionRecallPlugin,
|
||||
folder?: TFolder,
|
||||
onSelect?: (images: TFile[]) => any
|
||||
) {
|
||||
super(app);
|
||||
this.plugin = plugin;
|
||||
this.folder = folder;
|
||||
this.onSelect = onSelect;
|
||||
|
||||
}
|
||||
|
||||
async fetchFolders() {
|
||||
const folders = await this.plugin.helperService.retrieveAllFoldersWithPaths();
|
||||
return folders;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
const { contentEl, titleEl, modalEl } = this;
|
||||
modalEl.addClass('vr-select-images-modal');
|
||||
titleEl.setText('Select images from folder');
|
||||
const root = createRoot(contentEl);
|
||||
|
||||
root.render(
|
||||
<SelectImagesForm
|
||||
plugin={this.plugin}
|
||||
folder={this.folder}
|
||||
onClose={() => this.close()}
|
||||
onSelect={this.onSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
onClose() {
|
||||
const { contentEl } = this;
|
||||
contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
|
@ -6,13 +6,13 @@ import { DateTime } from 'luxon';
|
|||
import { DataManager } from '@/data/DataManager';
|
||||
import { getModels } from '@/services/llm-service';
|
||||
|
||||
interface TestConfigModalProps {
|
||||
interface TestSetupModalProps {
|
||||
dataManager: DataManager;
|
||||
plugin: VisionRecallPlugin;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TestConfigView: React.FC<TestConfigModalProps> = ({ dataManager, plugin, onClose }) => {
|
||||
const TestSetupView: React.FC<TestSetupModalProps> = ({ dataManager, plugin, onClose }) => {
|
||||
const settings = plugin.settings;
|
||||
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
|
|
@ -127,7 +127,7 @@ const TestConfigView: React.FC<TestConfigModalProps> = ({ dataManager, plugin, o
|
|||
);
|
||||
};
|
||||
|
||||
export class TestConfigModal extends Modal {
|
||||
export class TestSetupModal extends Modal {
|
||||
private metadata: any;
|
||||
private plugin: VisionRecallPlugin;
|
||||
|
||||
|
|
@ -139,11 +139,11 @@ export class TestConfigModal extends Modal {
|
|||
|
||||
onOpen() {
|
||||
const { contentEl, titleEl } = this;
|
||||
titleEl.setText('Test config');
|
||||
titleEl.setText('Test your setup');
|
||||
const root = createRoot(contentEl);
|
||||
|
||||
root.render(
|
||||
<TestConfigView
|
||||
<TestSetupView
|
||||
dataManager={this.plugin.dataManager}
|
||||
plugin={this.plugin}
|
||||
onClose={() => this.close()}
|
||||
|
|
@ -80,11 +80,12 @@ const TagCombobox: React.FC<TagComboboxProps> = ({
|
|||
/>
|
||||
|
||||
<button
|
||||
aria-label={`Make tags ${isInclusive ? "exclusive" : "inclusive"}`}
|
||||
// aria-label={`Make tags ${isInclusive ? "exclusive" : "inclusive"}`}
|
||||
aria-label={`Switch to ${isInclusive ? "ALL tags (exclusive)" : "ANY tag (inclusive)"}`}
|
||||
className="cursor-pointer"
|
||||
onClick={() => onInclusiveChange(!isInclusive)}
|
||||
>
|
||||
{isInclusive ? "Inclusive" : "Exclusive"}
|
||||
{isInclusive ? "ANY tag" : "ALL tags"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ export const IS_DEV = process.env.NODE_ENV === 'development'
|
|||
export const STORAGE_KEYS = {
|
||||
ITEMS_PER_PAGE: 'vision-recall-items-per-page',
|
||||
SORT_CRITERIA: 'vision-recall-sort-criteria',
|
||||
INITIALIZE_FOLDERS: 'vision-recall-initialize-folders'
|
||||
INITIALIZE_FOLDERS: 'vision-recall-initialize-folders',
|
||||
VIEW_MODE: 'vision-recall-view-mode'
|
||||
};
|
||||
|
||||
export const DEFAULT_ITEMS_PER_PAGE = 12;
|
||||
|
|
|
|||
123
src/docs/lm-studio.md
Normal file
123
src/docs/lm-studio.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# Using LM Studio with Obsidian Vision Recall
|
||||
|
||||
This guide will walk you through setting up LM Studio as your local LLM provider for the Obsidian Vision Recall plugin. LM Studio provides a user-friendly way to run powerful vision-capable language models locally on your computer.
|
||||
|
||||
## Why Choose LM Studio?
|
||||
|
||||
- **Privacy**: Your screenshots and data stay on your local machine
|
||||
- **No API Costs**: Avoid OpenAI API fees
|
||||
- **Customization**: Choose from a variety of open-source vision models
|
||||
- **User-Friendly**: Simple interface for downloading and running models
|
||||
- **Performance**: Optimized for running models on consumer hardware
|
||||
|
||||
## Installing LM Studio
|
||||
|
||||
1. Visit the [LM Studio website](https://lmstudio.ai/) and download the installer for your operating system:
|
||||
|
||||
- macOS (Apple Silicon or Intel)
|
||||
- Windows
|
||||
- Linux
|
||||
|
||||
2. Follow the installation instructions for your system:
|
||||
- **macOS**: Open the downloaded file and drag LM Studio to your Applications folder
|
||||
- **Windows**: Run the installer
|
||||
- **Linux**: Follow the installation instructions provided
|
||||
|
||||
## Setting Up LM Studio
|
||||
|
||||
1. **Launch LM Studio** from your applications folder or start menu
|
||||
|
||||
2. **Download a Vision-Capable Model**:
|
||||
|
||||
- Click on the "Browse Models" tab
|
||||
- Filter for models with vision capabilities (look for models with "vision" in their name)
|
||||
- Recommended models:
|
||||
- `LLama 3.2 Vision` (11b)
|
||||
- Click "Download" on your chosen model
|
||||
|
||||
3. **Download a Text Embedding Model**:
|
||||
|
||||
- Similar to the above instructions
|
||||
- Recommended models:
|
||||
- `nomic-embed-text`
|
||||
|
||||
4. **Start the Local Server**:
|
||||
- Click on the bottom tab to switch to `Power User` or `Developer`
|
||||
- Then, in the left sidebar, select the `Developer` option (probably an icon of a terminal)
|
||||
- Configure the settings
|
||||
- Enable all the options there, most likely
|
||||
- (Serve on Local Network, Enable CORS, Just-in-Time Model Loading)
|
||||
- (Optional) Select your downloaded vision model from the dropdown
|
||||
- Alternatively, the JIT mode could load this
|
||||
- Configure settings (if needed):
|
||||
- Context Length: Higher values allow processing larger images but use more memory
|
||||
- Temperature: 0.7 is a good starting point
|
||||
- Start the server (toggle the Status from Stopped to Running)
|
||||
- Note the server URL (typically `http://localhost:1234/v1`)
|
||||
|
||||
## Configuring Obsidian Vision Recall
|
||||
|
||||
1. Open Obsidian and go to Settings
|
||||
2. Click on "Vision Recall" in the left sidebar
|
||||
3. Under "LLM settings":
|
||||
- Set "LLM provider" to "OpenAI" (LM Studio uses the OpenAI API format)
|
||||
- Leave "API key" empty
|
||||
- Set "API base URL" to: `http://localhost:1234/v1`
|
||||
- _Note: The port may be different if you changed it in LM Studio_
|
||||
- Set "Vision model name" to the name of your model (e.g., `llama-3.2-11b-vision-instruct`)
|
||||
- Set "Endpoint LLM model name" to the same model name
|
||||
|
||||
## Verifying the Setup
|
||||
|
||||
To verify everything is working:
|
||||
|
||||
1. Click the Vision Recall icon in the Obsidian ribbon menu
|
||||
2. Click the "Test Config" button
|
||||
3. In the modal, click "Test Connection — Retrieve Models"
|
||||
4. If the connection is successful, you'll see your model listed
|
||||
|
||||
You can use the results from this test to properly fill in the `Vision model name` and `Endpoint LLM model name` in the settings
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
1. **Ensure LM Studio server is running**:
|
||||
|
||||
- Check the "Local Server" tab in LM Studio (usually actually under "Developer")
|
||||
- Verify the server status shows "Running"
|
||||
|
||||
2. **Check connection settings**:
|
||||
|
||||
- Verify the API base URL matches the URL shown in LM Studio
|
||||
- Make sure to include `/v1` at the end of the URL
|
||||
|
||||
3. **Model compatibility**:
|
||||
|
||||
- Not all models support vision capabilities
|
||||
- Ensure you've selected a vision-capable model in LM Studio
|
||||
|
||||
4. **Resource limitations**:
|
||||
|
||||
- Vision models require significant RAM and GPU resources
|
||||
- Try a smaller model if you experience crashes or slow performance
|
||||
|
||||
5. **Firewall issues**:
|
||||
- Ensure your firewall isn't blocking the LM Studio server port
|
||||
|
||||
## Optimizing Performance
|
||||
|
||||
- **Use smaller models** if you have limited system resources
|
||||
- **Reduce context length** in LM Studio for faster processing
|
||||
- **Close other resource-intensive applications** when using Vision Recall
|
||||
- **Consider GPU acceleration** if available on your system
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [LM Studio Documentation](https://lmstudio.ai/docs)
|
||||
- [Obsidian Vision Recall GitHub Repository](https://github.com/travisvn/obsidian-vision-recall)
|
||||
- [List of Vision-Capable Open Source Models](https://huggingface.co/models?pipeline_tag=image-to-text)
|
||||
|
||||
---
|
||||
|
||||
By following this guide, you can leverage the power of local vision language models through LM Studio to analyze and process your screenshots in Obsidian Vision Recall, maintaining privacy and avoiding API costs.
|
||||
263
src/lib/languages.ts
Normal file
263
src/lib/languages.ts
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
import { VisionRecallPluginSettings } from '@/types/settings-types'
|
||||
|
||||
export type Language = {
|
||||
code: string
|
||||
name: string
|
||||
nativeName: string
|
||||
}
|
||||
|
||||
export const languages: Record<string, Language> = {
|
||||
AFR: { code: 'afr', name: 'Afrikaans', nativeName: 'Afrikaans' },
|
||||
AMH: { code: 'amh', name: 'Amharic', nativeName: 'አማርኛ' },
|
||||
ARA: { code: 'ara', name: 'Arabic', nativeName: 'العربية' },
|
||||
ASM: { code: 'asm', name: 'Assamese', nativeName: 'অসমীয়া' },
|
||||
AZE: { code: 'aze', name: 'Azerbaijani', nativeName: 'Azərbaycanca' },
|
||||
AZE_CYRL: { code: 'aze_cyrl', name: 'Azerbaijani - Cyrillic', nativeName: 'Азәрбајҹан' },
|
||||
BEL: { code: 'bel', name: 'Belarusian', nativeName: 'Беларуская' },
|
||||
BEN: { code: 'ben', name: 'Bengali', nativeName: 'বাংলা' },
|
||||
BOD: { code: 'bod', name: 'Tibetan', nativeName: 'བོད་སྐད་' },
|
||||
BOS: { code: 'bos', name: 'Bosnian', nativeName: 'Bosanski' },
|
||||
BUL: { code: 'bul', name: 'Bulgarian', nativeName: 'Български' },
|
||||
CAT: { code: 'cat', name: 'Catalan', nativeName: 'Català' },
|
||||
CEB: { code: 'ceb', name: 'Cebuano', nativeName: 'Cebuano' },
|
||||
CES: { code: 'ces', name: 'Czech', nativeName: 'Čeština' },
|
||||
CHI_SIM: { code: 'chi_sim', name: 'Chinese - Simplified', nativeName: '简体中文' },
|
||||
CHI_TRA: { code: 'chi_tra', name: 'Chinese - Traditional', nativeName: '繁體中文' },
|
||||
CHR: { code: 'chr', name: 'Cherokee', nativeName: 'ᏣᎳᎩ' },
|
||||
CYM: { code: 'cym', name: 'Welsh', nativeName: 'Cymraeg' },
|
||||
DAN: { code: 'dan', name: 'Danish', nativeName: 'Dansk' },
|
||||
DEU: { code: 'deu', name: 'German', nativeName: 'Deutsch' },
|
||||
ELL: { code: 'ell', name: 'Greek', nativeName: 'Ελληνικά' },
|
||||
ENG: { code: 'eng', name: 'English', nativeName: 'English' },
|
||||
EPO: { code: 'epo', name: 'Esperanto', nativeName: 'Esperanto' },
|
||||
EST: { code: 'est', name: 'Estonian', nativeName: 'Eesti' },
|
||||
FAS: { code: 'fas', name: 'Persian', nativeName: 'فارسی' },
|
||||
FIN: { code: 'fin', name: 'Finnish', nativeName: 'Suomi' },
|
||||
FRA: { code: 'fra', name: 'French', nativeName: 'Français' },
|
||||
GLG: { code: 'glg', name: 'Galician', nativeName: 'Galego' },
|
||||
HEB: { code: 'heb', name: 'Hebrew', nativeName: 'עברית' },
|
||||
HIN: { code: 'hin', name: 'Hindi', nativeName: 'हिन्दी' },
|
||||
HRV: { code: 'hrv', name: 'Croatian', nativeName: 'Hrvatski' },
|
||||
HUN: { code: 'hun', name: 'Hungarian', nativeName: 'Magyar' },
|
||||
IND: { code: 'ind', name: 'Indonesian', nativeName: 'Bahasa Indonesia' },
|
||||
ISL: { code: 'isl', name: 'Icelandic', nativeName: 'Íslenska' },
|
||||
ITA: { code: 'ita', name: 'Italian', nativeName: 'Italiano' },
|
||||
JPN: { code: 'jpn', name: 'Japanese', nativeName: '日本語' },
|
||||
KOR: { code: 'kor', name: 'Korean', nativeName: '한국어' },
|
||||
LAV: { code: 'lav', name: 'Latvian', nativeName: 'Latviešu' },
|
||||
LIT: { code: 'lit', name: 'Lithuanian', nativeName: 'Lietuvių' },
|
||||
MAL: { code: 'mal', name: 'Malayalam', nativeName: 'മലയാളം' },
|
||||
MAR: { code: 'mar', name: 'Marathi', nativeName: 'मराठी' },
|
||||
MKD: { code: 'mkd', name: 'Macedonian', nativeName: 'Македонски' },
|
||||
MSA: { code: 'msa', name: 'Malay', nativeName: 'Bahasa Melayu' },
|
||||
MYA: { code: 'mya', name: 'Burmese', nativeName: 'မြန်မာစာ' },
|
||||
NLD: { code: 'nld', name: 'Dutch', nativeName: 'Nederlands' },
|
||||
NOR: { code: 'nor', name: 'Norwegian', nativeName: 'Norsk' },
|
||||
POL: { code: 'pol', name: 'Polish', nativeName: 'Polski' },
|
||||
POR: { code: 'por', name: 'Portuguese', nativeName: 'Português' },
|
||||
RON: { code: 'ron', name: 'Romanian', nativeName: 'Română' },
|
||||
RUS: { code: 'rus', name: 'Russian', nativeName: 'Русский' },
|
||||
SLK: { code: 'slk', name: 'Slovak', nativeName: 'Slovenčina' },
|
||||
SLV: { code: 'slv', name: 'Slovenian', nativeName: 'Slovenščina' },
|
||||
SPA: { code: 'spa', name: 'Spanish', nativeName: 'Español' },
|
||||
SWE: { code: 'swe', name: 'Swedish', nativeName: 'Svenska' },
|
||||
TUR: { code: 'tur', name: 'Turkish', nativeName: 'Türkçe' },
|
||||
UKR: { code: 'ukr', name: 'Ukrainian', nativeName: 'Українська' },
|
||||
URD: { code: 'urd', name: 'Urdu', nativeName: 'اردو' },
|
||||
VIE: { code: 'vie', name: 'Vietnamese', nativeName: 'Tiếng Việt' },
|
||||
YID: { code: 'yid', name: 'Yiddish', nativeName: 'ייִדיש' }
|
||||
}
|
||||
|
||||
export type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms languages into a format suitable for a select component.
|
||||
* Format: { value: "eng", label: "English (English)" }
|
||||
*/
|
||||
export const getLanguagesForSelect = (): SelectOption[] => {
|
||||
return getLanguagesArray().map((lang: Language) => ({
|
||||
value: lang.code,
|
||||
label: `${lang.name} (${lang.nativeName})`,
|
||||
}))
|
||||
}
|
||||
|
||||
export const getLanguagesForObsidianSettingsDropdown = (): Record<string, string> => {
|
||||
return getLanguagesArray().reduce((acc, lang) => {
|
||||
acc[lang.code] = `${lang.name} (${lang.nativeName})`;
|
||||
return acc;
|
||||
}, {} as Record<string, string>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of languages for selection.
|
||||
*/
|
||||
export const getLanguagesArray = (): Language[] => {
|
||||
return Object.values(languages)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a language object by its Tesseract.js code.
|
||||
*/
|
||||
export const getLanguageByCode = (code: string): Language | undefined => {
|
||||
return Object.values(languages).find(lang => lang.code === code)
|
||||
}
|
||||
|
||||
|
||||
export const getLanguagePromptModifierIfIndicated = (config: VisionRecallPluginSettings, extended: boolean = false): string => {
|
||||
if (config.addLanguageConvertToPrompt && config.tesseractLanguage && config.tesseractLanguage !== 'eng') {
|
||||
const language = getLanguageByCode(config.tesseractLanguage as string)
|
||||
if (language) {
|
||||
let extendedModifier = '';
|
||||
if (extended) {
|
||||
extendedModifier = ` (${language.nativeName})`;
|
||||
}
|
||||
// Maybe "language.name (language.nativeName)"
|
||||
return `\n\nGenerate the response in the following language: ${language.name}${extendedModifier}\n\n`
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export const languageRegexMap_ScriptRegex: Record<string, RegExp> = {
|
||||
afr: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:; ]+$/,
|
||||
amh: /^[\p{Script=Ethiopic}0-9.,!?'"()\-:; ]+$/u,
|
||||
ara: /^[\p{Script=Arabic}0-9.,!?'"()\-:;،؛ ]+$/u,
|
||||
asm: /^[\p{Script=Bengali}0-9.,!?'"()\-:; ]+$/u,
|
||||
aze: /^[a-zA-ZƏəĞğİıÖöŞşÜüÇç0-9.,!?'"()\-:; ]+$/,
|
||||
aze_cyrl: /^[\p{Script=Cyrillic}0-9.,!?'"()\-:; ]+$/u,
|
||||
bel: /^[\p{Script=Cyrillic}0-9.,!?'"()\-:;«» ]+$/u,
|
||||
ben: /^[\p{Script=Bengali}0-9.,!?'"()\-:; ]+$/u,
|
||||
bod: /^[\p{Script=Tibetan}0-9.,!?'"()\-:; ]+$/u,
|
||||
bos: /^[a-zA-ZČčĆćĐ𩹮ž0-9.,!?'"()\-:; ]+$/,
|
||||
bul: /^[\p{Script=Cyrillic}0-9.,!?'"()\-:;«» ]+$/u,
|
||||
cat: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:;«» ]+$/,
|
||||
ceb: /^[a-zA-ZÑñ0-9.,!?'"()\-:; ]+$/,
|
||||
ces: /^[a-zA-ZÁČĎÉĚÍŇÓŘŠŤÚŮÝŽáčďéěíňóřšťúůýž0-9.,!?'"()\-:; ]+$/,
|
||||
chi_sim: /^[\p{Script=Han}0-9.,!?'"()\-:;、。《》]+$/u,
|
||||
chi_tra: /^[\p{Script=Han}0-9.,!?'"()\-:;、。《》]+$/u,
|
||||
chr: /^[\p{Script=Cherokee}0-9.,!?'"()\-:; ]+$/u,
|
||||
cym: /^[a-zA-Zŵŷáéíóúàèìòùâêîôû0-9.,!?'"()\-:; ]+$/,
|
||||
dan: /^[a-zA-ZÆØÅæøå0-9.,!?'"()\-:; ]+$/,
|
||||
deu: /^[a-zA-ZÄÖÜäöüß0-9.,!?'"()\-:;„“– ]+$/,
|
||||
ell: /^[\p{Script=Greek}0-9.,!?'"()\-:;«» ]+$/u,
|
||||
eng: /^[a-zA-Z0-9.,!?'"()\-:; ]+$/,
|
||||
epo: /^[a-zA-ZĈĉĜĝĤĥĴĵŜŝŬŭ0-9.,!?'"()\-:; ]+$/,
|
||||
est: /^[a-zA-ZÄÖÜäöüÕõŠšŽž0-9.,!?'"()\-:; ]+$/,
|
||||
fas: /^[\p{Script=Arabic}0-9.,!?'"()\-:;،؛ ]+$/u,
|
||||
fin: /^[a-zA-ZÄÖäöÅå0-9.,!?'"()\-:; ]+$/,
|
||||
fra: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:;«» ]+$/,
|
||||
glg: /^[a-zA-ZÁÉÍÓÚÜÑáéíóúüñ0-9.,!?'"()\-:; ]+$/,
|
||||
heb: /^[\p{Script=Hebrew}0-9.,!?'"()\-:;״ ]+$/u,
|
||||
hin: /^[\p{Script=Devanagari}0-9.,!?'"()\-:; ]+$/u,
|
||||
hrv: /^[a-zA-ZČčĆćĐ𩹮ž0-9.,!?'"()\-:; ]+$/,
|
||||
hun: /^[a-zA-ZÁÉÍÓÖŐÚÜŰáéíóöőúüű0-9.,!?'"()\-:; ]+$/,
|
||||
ind: /^[a-zA-Z0-9.,!?'"()\-:; ]+$/,
|
||||
isl: /^[a-zA-ZÁÉÍÓÚÝÞÆÐÖáéíóúýþæðö0-9.,!?'"()\-:; ]+$/,
|
||||
ita: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:;«» ]+$/,
|
||||
jpn: /^[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}0-9.,!?'"()\-:;「」『』]+$/u,
|
||||
kor: /^[\p{Script=Hangul}0-9.,!?'"()\-:; ]+$/u,
|
||||
lav: /^[a-zA-ZĀČĒĢĪĶĻŅŌŖŠŪŽāčēģīķļņōŗšūž0-9.,!?'"()\-:; ]+$/,
|
||||
lit: /^[a-zA-ZĄČĘĖĮŠŲŪŽąčęėįšųūž0-9.,!?'"()\-:; ]+$/,
|
||||
mal: /^[\p{Script=Malayalam}0-9.,!?'"()\-:; ]+$/u,
|
||||
mar: /^[\p{Script=Devanagari}0-9.,!?'"()\-:; ]+$/u,
|
||||
mkd: /^[\p{Script=Cyrillic}0-9.,!?'"()\-:;«» ]+$/u,
|
||||
msa: /^[a-zA-Z0-9.,!?'"()\-:; ]+$/,
|
||||
mya: /^[\p{Script=Myanmar}0-9.,!?'"()\-:; ]+$/u,
|
||||
nld: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:; ]+$/,
|
||||
nor: /^[a-zA-ZÆØÅæøå0-9.,!?'"()\-:; ]+$/,
|
||||
pol: /^[a-zA-ZĄĆĘŁŃÓŚŹŻąćęłńóśźż0-9.,!?'"()\-:; ]+$/,
|
||||
por: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:;«» ]+$/,
|
||||
ron: /^[a-zA-ZĂÂÎȘȚăâîșț0-9.,!?'"()\-:; ]+$/,
|
||||
rus: /^[\p{Script=Cyrillic}0-9.,!?'"()\-:;«» ]+$/u,
|
||||
slk: /^[a-zA-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽáäčďéíĺľňóôŕšťúýž0-9.,!?'"()\-:; ]+$/,
|
||||
slv: /^[a-zA-ZČŠŽčšž0-9.,!?'"()\-:; ]+$/,
|
||||
spa: /^[a-zA-ZÁÉÍÓÚÑÜáéíóúñü0-9.,!?'"()\-:;«»¡¿ ]+$/,
|
||||
swe: /^[a-zA-ZÅÄÖåäö0-9.,!?'"()\-:; ]+$/,
|
||||
tur: /^[a-zA-ZÇĞİÖŞÜçğıöşü0-9.,!?'"()\-:; ]+$/,
|
||||
ukr: /^[\p{Script=Cyrillic}0-9.,!?'"()\-:;«» ]+$/u,
|
||||
urd: /^[\p{Script=Arabic}0-9.,!?'"()\-:;،؛ ]+$/u,
|
||||
vie: /^[a-zA-ZÀ-ỹ0-9.,!?'"()\-:; ]+$/,
|
||||
yid: /^[\p{Script=Hebrew}0-9.,!?'"()\-:;״ ]+$/u,
|
||||
all: /^[\p{L}\p{N}.,!?'"()\-:;«»„“‘’「」『』¡¿،؛《》 ]+$/u,
|
||||
};
|
||||
|
||||
export const languageRegexMap: Record<string, RegExp> = {
|
||||
afr: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:; ]+$/, // Afrikaans
|
||||
amh: /^[\u1200-\u137F0-9.,!?'"()\-:; ]+$/, // Amharic (Ethiopic)
|
||||
ara: /^[\u0600-\u06FF0-9.,!?'"()\-:;،؛ ]+$/, // Arabic
|
||||
asm: /^[\u0980-\u09FF0-9.,!?'"()\-:; ]+$/, // Assamese (Bengali script)
|
||||
aze: /^[a-zA-ZƏəĞğİıÖöŞşÜüÇç0-9.,!?'"()\-:; ]+$/, // Azerbaijani (Latin)
|
||||
aze_cyrl: /^[\u0400-\u04FF0-9.,!?'"()\-:; ]+$/, // Azerbaijani (Cyrillic)
|
||||
bel: /^[\u0400-\u04FF0-9.,!?'"()\-:;«» ]+$/, // Belarusian (Cyrillic)
|
||||
ben: /^[\u0980-\u09FF0-9.,!?'"()\-:; ]+$/, // Bengali
|
||||
bod: /^[\u0F00-\u0FFF0-9.,!?'"()\-:; ]+$/, // Tibetan
|
||||
bos: /^[a-zA-ZČčĆćĐ𩹮ž0-9.,!?'"()\-:; ]+$/, // Bosnian
|
||||
bul: /^[\u0400-\u04FF0-9.,!?'"()\-:;«» ]+$/, // Bulgarian (Cyrillic)
|
||||
cat: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:;«» ]+$/, // Catalan
|
||||
ceb: /^[a-zA-ZÑñ0-9.,!?'"()\-:; ]+$/, // Cebuano
|
||||
ces: /^[a-zA-ZÁČĎÉĚÍŇÓŘŠŤÚŮÝŽáčďéěíňóřšťúůýž0-9.,!?'"()\-:; ]+$/, // Czech
|
||||
chi_sim: /^[\u4E00-\u9FFF0-9.,!?'"()\-:;、。《》]+$/, // Chinese - Simplified
|
||||
chi_tra: /^[\u4E00-\u9FFF0-9.,!?'"()\-:;、。《》]+$/, // Chinese - Traditional
|
||||
chr: /^[\u13A0-\u13FF0-9.,!?'"()\-:; ]+$/, // Cherokee
|
||||
cym: /^[a-zA-Zŵŷáéíóúàèìòùâêîôû0-9.,!?'"()\-:; ]+$/, // Welsh
|
||||
dan: /^[a-zA-ZÆØÅæøå0-9.,!?'"()\-:; ]+$/, // Danish
|
||||
deu: /^[a-zA-ZÄÖÜäöüß0-9.,!?'"()\-:;„“– ]+$/, // German
|
||||
ell: /^[\u0370-\u03FF0-9.,!?'"()\-:;«» ]+$/, // Greek
|
||||
eng: /^[a-zA-Z0-9.,!?'"()\-:; ]+$/, // English
|
||||
epo: /^[a-zA-ZĈĉĜĝĤĥĴĵŜŝŬŭ0-9.,!?'"()\-:; ]+$/, // Esperanto
|
||||
est: /^[a-zA-ZÄÖÜäöüÕõŠšŽž0-9.,!?'"()\-:; ]+$/, // Estonian
|
||||
fas: /^[\u0600-\u06FF0-9.,!?'"()\-:;،؛ ]+$/, // Persian (Arabic script)
|
||||
fin: /^[a-zA-ZÄÖäöÅå0-9.,!?'"()\-:; ]+$/, // Finnish
|
||||
fra: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:;«» ]+$/, // French
|
||||
glg: /^[a-zA-ZÁÉÍÓÚÜÑáéíóúüñ0-9.,!?'"()\-:; ]+$/, // Galician
|
||||
heb: /^[\u0590-\u05FF0-9.,!?'"()\-:;״ ]+$/, // Hebrew
|
||||
hin: /^[\u0900-\u097F0-9.,!?'"()\-:; ]+$/, // Hindi (Devanagari)
|
||||
hrv: /^[a-zA-ZČčĆćĐ𩹮ž0-9.,!?'"()\-:; ]+$/, // Croatian
|
||||
hun: /^[a-zA-ZÁÉÍÓÖŐÚÜŰáéíóöőúüű0-9.,!?'"()\-:; ]+$/, // Hungarian
|
||||
isl: /^[a-zA-ZÁÉÍÓÚÝÞÆÐÖáéíóúýþæðö0-9.,!?'"()\-:; ]+$/, // Icelandic
|
||||
ita: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:;«» ]+$/, // Italian
|
||||
jpn: /^[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF01-\uFF5E0-9.,!?'"()\-:;「」『』]+$/, // Japanese
|
||||
kor: /^[\uAC00-\uD7AF0-9.,!?'"()\-:; ]+$/, // Korean (Hangul)
|
||||
msa: /^[a-zA-Z0-9.,!?'"()\-:; ]+$/, // Malay
|
||||
mya: /^[\u1000-\u109F0-9.,!?'"()\-:; ]+$/, // Burmese (Myanmar script)
|
||||
pol: /^[a-zA-ZĄĆĘŁŃÓŚŹŻąćęłńóśźż0-9.,!?'"()\-:; ]+$/, // Polish
|
||||
por: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:;«» ]+$/, // Portuguese
|
||||
ron: /^[a-zA-ZĂÂÎȘȚăâîșț0-9.,!?'"()\-:; ]+$/, // Romanian
|
||||
rus: /^[\u0400-\u04FF0-9.,!?'"()\-:;«» ]+$/, // Russian
|
||||
slk: /^[a-zA-ZÁÄČĎÉÍĹĽŇÓÔŔŠŤÚÝŽáäčďéíĺľňóôŕšťúýž0-9.,!?'"()\-:; ]+$/, // Slovak
|
||||
slv: /^[a-zA-ZČŠŽčšž0-9.,!?'"()\-:; ]+$/, // Slovenian
|
||||
spa: /^[a-zA-ZÁÉÍÓÚÑÜáéíóúñü0-9.,!?'"()\-:;«»¡¿ ]+$/, // Spanish
|
||||
swe: /^[a-zA-ZÅÄÖåäö0-9.,!?'"()\-:; ]+$/, // Swedish
|
||||
tur: /^[a-zA-ZÇĞİÖŞÜçğıöşü0-9.,!?'"()\-:; ]+$/, // Turkish
|
||||
ukr: /^[\u0400-\u04FF0-9.,!?'"()\-:;«» ]+$/, // Ukrainian
|
||||
urd: /^[\u0600-\u06FF0-9.,!?'"()\-:;،؛ ]+$/, // Urdu
|
||||
vie: /^[a-zA-ZÀ-ỹ0-9.,!?'"()\-:; ]+$/, // Vietnamese
|
||||
yid: /^[\u0590-\u05FF0-9.,!?'"()\-:;״ ]+$/, // Yiddish (Hebrew script)
|
||||
all: /^[\p{L}\p{N}.,!?'"()\-:;«»„“‘’「」『』¡¿،؛《》 ]+$/u, // Default: all scripts
|
||||
};
|
||||
|
||||
export function cleanOCRResultLanguageSpecificOld(ocrText: string, language: string = 'all'): string {
|
||||
const allowedRegex = languageRegexMap[language] || languageRegexMap['all'];
|
||||
return ocrText.split('\n').filter(line => !!line.trim() && allowedRegex.test(line.trim())).join('\n');
|
||||
}
|
||||
|
||||
// Checks characters individually to see if they're within our RegEx for the language
|
||||
export function cleanOCRResultLanguageSpecific(text: string, language: string = 'all'): string {
|
||||
// Get the regex for the specified language or use the default 'all' regex
|
||||
const allowedRegex = languageRegexMap_ScriptRegex[language] || languageRegexMap_ScriptRegex['all'];
|
||||
|
||||
// Filter out characters that do not match the regex
|
||||
const filteredText = [...text].filter(char => char === '\n' || allowedRegex.test(char)).join('');
|
||||
|
||||
return filteredText;
|
||||
}
|
||||
|
||||
export function getLanguageSetting(settings: VisionRecallPluginSettings): string | null {
|
||||
if (settings.addLanguageConvertToPrompt && settings.tesseractLanguage) {
|
||||
return settings.tesseractLanguage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,37 +1,53 @@
|
|||
import { languageRegexMap } from '@/lib/languages';
|
||||
import { franc } from 'franc';
|
||||
|
||||
export function isValidLanguage(text: string): boolean {
|
||||
export function isValidLanguage(text: string, expectedLanguage?: string): boolean {
|
||||
const detectedLanguage = franc(text); // Detect language
|
||||
if (expectedLanguage) {
|
||||
return detectedLanguage === expectedLanguage; // Ensure it matches expected language
|
||||
}
|
||||
return detectedLanguage !== 'und'; // 'und' means undefined
|
||||
}
|
||||
|
||||
export function isMostlyValidCharacters(text: string): boolean {
|
||||
const validCharRegex = /^[\w\s.,!?'"-–—()]+$/u; // Adjust regex for more languages if needed
|
||||
const validChars = text.split('').filter(char => validCharRegex.test(char)).length;
|
||||
export function isMostlyValidCharacters(text: string, language: string = 'all'): boolean {
|
||||
// Use the regex from `cleanOCRResult` based on the language
|
||||
// const languageRegexMap: Record<string, RegExp> = {
|
||||
// en: /^[a-zA-Z0-9.,!?'"()\-:; ]+$/,
|
||||
// fra: /^[a-zA-ZÀ-ÿ0-9.,!?'"()\-:; ]+$/,
|
||||
// rus: /^[\p{Script=Cyrillic}0-9.,!?'"()\-:; ]+$/u,
|
||||
// jpn: /^[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}0-9.,!?'"()\-:; ]+$/u,
|
||||
// all: /^[\p{L}\p{N}.,!?'"()\-:; ]+$/u, // Default: any Unicode letter or number
|
||||
// };
|
||||
|
||||
const allowedRegex = languageRegexMap[language] || languageRegexMap['all'];
|
||||
|
||||
const validChars = text.split('').filter(char => allowedRegex.test(char)).length;
|
||||
return validChars / text.length > 0.8; // At least 80% valid characters
|
||||
}
|
||||
|
||||
export function isValidTextLength(text: string): boolean {
|
||||
return text.length > 5 && text.length < 5000; // Arbitrary thresholds
|
||||
export function isValidTextLength(text: string, minLength = 5, maxLength = 5000): boolean {
|
||||
return text.length >= minLength && text.length <= maxLength;
|
||||
}
|
||||
|
||||
export async function checkOCRText(text: string): Promise<string | null> {
|
||||
export async function checkOCRText(text: string, expectedLanguage?: string): Promise<string | null> {
|
||||
// Clean the text first
|
||||
// const cleanedText = cleanOCRResult(text, expectedLanguage || 'all');
|
||||
|
||||
// Combine validation checks
|
||||
if (!isValidTextLength(text)) {
|
||||
console.warn('Text length is invalid.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isMostlyValidCharacters(text)) {
|
||||
if (!isMostlyValidCharacters(text, expectedLanguage || 'all')) {
|
||||
console.warn('Text contains too many invalid characters.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isValidLanguage(text)) {
|
||||
// maybe remove this
|
||||
if (!isValidLanguage(text, expectedLanguage)) {
|
||||
console.warn('Language detection failed.');
|
||||
return null;
|
||||
}
|
||||
|
||||
return text; // Process further if valid
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import { z } from 'zod';
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { cleanOCRResultLanguageSpecific, getLanguageSetting } from './languages';
|
||||
import { VisionRecallPluginSettings } from '@/types/settings-types';
|
||||
// Define the Zod schema
|
||||
export const TagsSchema = z.object({
|
||||
title: z.string(),
|
||||
|
|
@ -39,7 +41,7 @@ export function tagsFromCommaString(tags: string): string[] {
|
|||
return tags.split(',').map(tag => tag.trim());
|
||||
}
|
||||
|
||||
export function sanitizeObsidianTag(tag: string): string | null {
|
||||
export function sanitizeObsidianTagOLD(tag: string): string | null {
|
||||
// Replace spaces with underscores to maintain readability
|
||||
tag = tag.replace(/\s+/g, '_');
|
||||
|
||||
|
|
@ -53,5 +55,26 @@ export function sanitizeObsidianTag(tag: string): string | null {
|
|||
|
||||
// Convert to lowercase to ensure case insensitivity
|
||||
// return tag.toLowerCase();
|
||||
return tag;
|
||||
}
|
||||
|
||||
export function sanitizeObsidianTag(tag: string, settings: VisionRecallPluginSettings): string | null {
|
||||
// Replace spaces with underscores to maintain readability
|
||||
tag = tag.replace(/\s+/g, '_');
|
||||
|
||||
const language = getLanguageSetting(settings);
|
||||
|
||||
if (language) {
|
||||
tag = cleanOCRResultLanguageSpecific(tag, language);
|
||||
} else {
|
||||
// Remove all disallowed characters (keep letters, numbers, _, -, /)
|
||||
tag = tag.replace(/[^a-zA-Z0-9_\/-]/g, '');
|
||||
|
||||
// Ensure there's at least one non-numeric character
|
||||
if (!/[a-zA-Z_\/-]/.test(tag)) {
|
||||
return null; // Return null if there's no valid non-numeric character
|
||||
}
|
||||
}
|
||||
|
||||
return tag;
|
||||
}
|
||||
26
src/main.ts
26
src/main.ts
|
|
@ -16,6 +16,7 @@ import { StatusBarManager } from './lib/StatusBarManager';
|
|||
import { debounce } from 'obsidian';
|
||||
import { customParse } from './lib/json-utils';
|
||||
import { DefaultConfig } from './types/config-types';
|
||||
import { HelperService } from './services/helper-service';
|
||||
|
||||
export default class VisionRecallPlugin extends Plugin {
|
||||
settings: VisionRecallPluginSettings;
|
||||
|
|
@ -24,6 +25,7 @@ export default class VisionRecallPlugin extends Plugin {
|
|||
logger: PluginLogger;
|
||||
processingQueue: ProcessingQueue;
|
||||
statusBarManager: StatusBarManager;
|
||||
helperService: HelperService;
|
||||
|
||||
metadata: any[] = [];
|
||||
debouncedOnCreateHandler: (file: TAbstractFile) => void;
|
||||
|
|
@ -37,6 +39,7 @@ export default class VisionRecallPlugin extends Plugin {
|
|||
this.screenshotProcessor = new ScreenshotProcessor(this.app, this.settings, this);
|
||||
this.processingQueue = new ProcessingQueue(this, useQueueStore);
|
||||
this.statusBarManager = new StatusBarManager(this);
|
||||
this.helperService = new HelperService(this);
|
||||
|
||||
this.initializeUI();
|
||||
await this.registerEventListeners();
|
||||
|
|
@ -194,14 +197,6 @@ export default class VisionRecallPlugin extends Plugin {
|
|||
return config.enablePeriodicIntakeFolderProcessing;
|
||||
}
|
||||
|
||||
async getIntakeFromVaultFolderEnabled(): Promise<boolean> {
|
||||
return this.settings.intakeFromVaultFolder;
|
||||
}
|
||||
|
||||
async getLimitRootFolderIntakeToCSVStrings(): Promise<string[]> {
|
||||
return this.settings.limitIntakeToCSV?.split(',').map(x => x.trim().toLowerCase()) || [];
|
||||
}
|
||||
|
||||
async onFileCreated(file: TAbstractFile) {
|
||||
const autoProcessingEnabled = await this.autoProcessingEnabled();
|
||||
if (!autoProcessingEnabled) return;
|
||||
|
|
@ -212,20 +207,7 @@ export default class VisionRecallPlugin extends Plugin {
|
|||
if (!IMAGE_EXTENSIONS.includes(ext)) return;
|
||||
|
||||
const screenshotIntakeFolderPath = await this.getFolderFromSettingsKey('screenshotIntakeFolderPath');
|
||||
if (!file.path.startsWith(screenshotIntakeFolderPath)) {
|
||||
|
||||
const intakeFromVaultFolderEnabled = await this.getIntakeFromVaultFolderEnabled();
|
||||
if (intakeFromVaultFolderEnabled) {
|
||||
const limitIntakeToCSV = await this.getLimitRootFolderIntakeToCSVStrings();
|
||||
if (limitIntakeToCSV.length > 0) {
|
||||
const filename = file.name.toLowerCase();
|
||||
if (!limitIntakeToCSV.some(x => filename.includes(x))) return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
if (!file.path.startsWith(screenshotIntakeFolderPath)) return;
|
||||
|
||||
if (await shouldProcessImage(this, file)) {
|
||||
this.logger.info(`Processing new image: ${file.path}`);
|
||||
|
|
|
|||
103
src/services/helper-service.ts
Normal file
103
src/services/helper-service.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import { SelectImagesModal } from '@/components/modals/SelectImagesModal';
|
||||
import { IMAGE_EXTENSIONS } from '@/constants';
|
||||
import { shouldProcessImage } from '@/lib/image-utils';
|
||||
import VisionRecallPlugin from '@/main';
|
||||
import { FolderWithPath } from '@/types/shared-types';
|
||||
import { App, TFile, TFolder } from 'obsidian';
|
||||
|
||||
|
||||
export class HelperService {
|
||||
plugin: VisionRecallPlugin;
|
||||
app: App;
|
||||
|
||||
constructor(
|
||||
plugin: VisionRecallPlugin,
|
||||
) {
|
||||
this.plugin = plugin;
|
||||
this.app = plugin.app;
|
||||
}
|
||||
|
||||
async retrieveFolders(folder: TFolder): Promise<TFolder[]> {
|
||||
const allFiles = folder.children;
|
||||
return allFiles.filter(file => file instanceof TFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all folders in the vault recursively
|
||||
* @returns An array of TFolder objects representing all directories in the vault
|
||||
*/
|
||||
async retrieveAllFolders(): Promise<TFolder[]> {
|
||||
const rootFolder = this.app.vault.getRoot();
|
||||
const allFolders: TFolder[] = [rootFolder];
|
||||
|
||||
// Helper function to recursively collect all folders
|
||||
const collectFolders = async (folder: TFolder) => {
|
||||
const subFolders = await this.retrieveFolders(folder);
|
||||
allFolders.push(...subFolders);
|
||||
|
||||
for (const subFolder of subFolders) {
|
||||
await collectFolders(subFolder);
|
||||
}
|
||||
};
|
||||
|
||||
await collectFolders(rootFolder);
|
||||
return allFolders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves all folders in the vault with their paths
|
||||
* @returns An array of objects containing the folder and its path
|
||||
*/
|
||||
async retrieveAllFoldersWithPaths(): Promise<FolderWithPath[]> {
|
||||
const folders = await this.retrieveAllFolders();
|
||||
return folders.map(folder => {
|
||||
return {
|
||||
folder: folder,
|
||||
path: folder.path === '/' ? '/' : folder.path
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async retrieveImagesFromFolder(folder: TFolder, shouldProcessCheck: boolean = true): Promise<TFile[]> {
|
||||
if (!folder) {
|
||||
this.plugin.logger.warn('Folder not configured');
|
||||
return [];
|
||||
}
|
||||
|
||||
const allFiles = folder.children;
|
||||
|
||||
const files = allFiles.filter(file => {
|
||||
if (!(file instanceof TFile)) return false;
|
||||
const ext = file.extension.toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext);
|
||||
});
|
||||
|
||||
let selectedFiles: TFile[] = [];
|
||||
|
||||
if (files.length > 0) {
|
||||
for (const file of files) {
|
||||
if (file instanceof TFile) {
|
||||
if (shouldProcessCheck && await shouldProcessImage(this.plugin, file)) {
|
||||
selectedFiles.push(file);
|
||||
} else {
|
||||
selectedFiles.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return selectedFiles;
|
||||
}
|
||||
|
||||
async retrieveImagesFromVaultRoot(): Promise<TFile[]> {
|
||||
const vaultFolder: TFolder = this.app.vault.getRoot();
|
||||
return this.retrieveImagesFromFolder(vaultFolder);
|
||||
}
|
||||
|
||||
async selectImages(folder: TFolder) {
|
||||
const modal = new SelectImagesModal(this.plugin.app, this.plugin, folder, (selectedImages) => {
|
||||
this.plugin.processingQueue.addMultipleToQueue(selectedImages);
|
||||
});
|
||||
modal.open();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,30 @@
|
|||
import { OPENROUTER_HEADERS } from '@/constants';
|
||||
import { getLanguagePromptModifierIfIndicated } from '@/lib/languages';
|
||||
import { tagsJsonSchema, TagsSchema } from '@/lib/tag-utils';
|
||||
import { Config } from '@/types/config-types';
|
||||
import { VisionRecallPluginSettings } from '@/types/settings-types';
|
||||
import { requestUrl, RequestUrlParam, RequestUrlResponse } from 'obsidian';
|
||||
|
||||
export const VISION_LLM_PROMPT = "Analyze this screenshot and describe its content and identify the type of screenshot if possible.";
|
||||
|
||||
export const NOTES_LLM_PROMPT = "The following OCR text and vision analysis are from a screenshot. Summarize and synthesize the text and vision analysis and identify key information.";
|
||||
|
||||
export const getVisionLLMPrompt = (config: Config) => {
|
||||
if (config.visionLLMPrompt && config.visionLLMPrompt.trim() !== '') {
|
||||
return config.visionLLMPrompt;
|
||||
}
|
||||
|
||||
return VISION_LLM_PROMPT;
|
||||
}
|
||||
|
||||
export const getNotesLLMPrompt = (config: Config) => {
|
||||
if (config.notesLLMPrompt && config.notesLLMPrompt.trim() !== '') {
|
||||
return config.notesLLMPrompt;
|
||||
}
|
||||
|
||||
return NOTES_LLM_PROMPT;
|
||||
}
|
||||
|
||||
export function adjustEndpoint(apiEndpointUrl: string, shouldRemove: boolean): string {
|
||||
const suffix = '/v1'
|
||||
|
||||
|
|
@ -127,7 +147,8 @@ export async function llmSuggestTagsAndTitle(settings: VisionRecallPluginSetting
|
|||
}
|
||||
|
||||
try {
|
||||
const tagPrompt = `Please suggest exactly 5 relevant tags or keywords to categorize the following notes as well as a title for the notes. Return ONLY a JSON object with the following properties: tags (array of strings), title (string). Example format: { title: 'Title of the notes', tags: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'] }\n\nNotes:\n${notesText}`;
|
||||
const languagePromptModifier = getLanguagePromptModifierIfIndicated(settings)
|
||||
const tagPrompt = `Please suggest exactly 5 relevant tags or keywords to categorize the following notes as well as a title for the notes. Return ONLY a JSON object with the following properties: tags (array of strings), title (string). Example format: { title: 'Title of the notes', tags: ['tag1', 'tag2', 'tag3', 'tag4', 'tag5'] }${languagePromptModifier}\n\nNotes:\n${notesText}`;
|
||||
|
||||
const tagPayload = {
|
||||
model: settings.endpointLlmModelName,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { App, FileManager, Notice, TFile, TFolder, arrayBufferToBase64, normalizePath } from 'obsidian';
|
||||
import Tesseract, { createWorker, Worker } from 'tesseract.js';
|
||||
import { VisionRecallPluginSettings } from '@/types/settings-types';
|
||||
import { DEFAULT_TAGS_AND_TITLE, TagsAndTitle, VISION_LLM_PROMPT, callLLMAPI } from '@/services/llm-service';
|
||||
import { DEFAULT_TAGS_AND_TITLE, TagsAndTitle, VISION_LLM_PROMPT, callLLMAPI, getNotesLLMPrompt, getVisionLLMPrompt } from '@/services/llm-service';
|
||||
import VisionRecallPlugin from '@/main';
|
||||
import { checkOCRText } from '@/lib/ocr-validation';
|
||||
import { formatTags, sanitizeObsidianTag, tagsToCommaString } from '@/lib/tag-utils';
|
||||
import { useQueueStore } from '@/stores/queueStore';
|
||||
import { visionLLMResponseCategoriesMap } from '@/data/reference';
|
||||
import { computeFileHash, shouldProcessImage } from '@/lib/image-utils';
|
||||
import { sanitizeFilename } from './shared-functions';
|
||||
import { sanitizeFilename, sanitizeObsidianTitle } from './shared-functions';
|
||||
import { generateTagsWithRetries } from './tag-service';
|
||||
import { IMAGE_EXTENSIONS } from '@/constants';
|
||||
import { cleanOCRResultLanguageSpecific, getLanguagePromptModifierIfIndicated, getLanguageSetting } from '@/lib/languages';
|
||||
|
||||
export type DeleteScreenshotMetadataParams = {
|
||||
identity: string;
|
||||
|
|
@ -78,17 +79,21 @@ class ProgressManager {
|
|||
export class ScreenshotProcessor {
|
||||
app: App;
|
||||
plugin: VisionRecallPlugin;
|
||||
settings: VisionRecallPluginSettings;
|
||||
tesseractLanguage: string | null;
|
||||
private worker: Worker | null = null;
|
||||
private progressManager: ProgressManager;
|
||||
// private logger: PluginLogger;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private settings: VisionRecallPluginSettings,
|
||||
settings: VisionRecallPluginSettings,
|
||||
plugin: VisionRecallPlugin
|
||||
) {
|
||||
this.app = app;
|
||||
this.plugin = plugin;
|
||||
this.settings = settings;
|
||||
this.tesseractLanguage = settings.addLanguageConvertToPrompt ? settings.tesseractLanguage : 'eng';
|
||||
// this.plugin.logger = plugin.logger;
|
||||
this.progressManager = new ProgressManager(plugin);
|
||||
// this.initializeWorker();
|
||||
|
|
@ -99,9 +104,11 @@ export class ScreenshotProcessor {
|
|||
}
|
||||
|
||||
async initializeWorker() {
|
||||
let currentTesseractLanguage = this.settings.addLanguageConvertToPrompt ? this.settings.tesseractLanguage : 'eng';
|
||||
try {
|
||||
this.worker = await createWorker();
|
||||
this.worker = await createWorker(currentTesseractLanguage);
|
||||
this.plugin.logger.debug('Tesseract worker initialized.');
|
||||
this.tesseractLanguage = currentTesseractLanguage;
|
||||
} catch (error) {
|
||||
this.plugin.logger.error('Error initializing Tesseract worker:', error);
|
||||
new Notice('Failed to initialize OCR worker. See console for details.');
|
||||
|
|
@ -117,6 +124,11 @@ export class ScreenshotProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
async reinitializeWorker() {
|
||||
await this.terminateWorker();
|
||||
await this.initializeWorker();
|
||||
}
|
||||
|
||||
private async initializeProcessing(imageFile: TFile): Promise<boolean> {
|
||||
if (this.worker === null) {
|
||||
new Notice('Initializing Tesseract worker...');
|
||||
|
|
@ -149,26 +161,33 @@ export class ScreenshotProcessor {
|
|||
tagsAndTitle: TagsAndTitle;
|
||||
formattedTags: string;
|
||||
} | null> {
|
||||
const currentLanguageSetting = getLanguageSetting(this.settings);
|
||||
this.plugin.logger.debug('Current language setting:', currentLanguageSetting);
|
||||
|
||||
this.progressManager.updateProgress('Performing OCR...', 10);
|
||||
if (this.progressManager.isStoppedByUser()) return null;
|
||||
|
||||
const ocrText = await this.performOCR(imageFile);
|
||||
let ocrText = await this.performOCR(imageFile, currentLanguageSetting);
|
||||
if (!ocrText || this.progressManager.isStoppedByUser()) return null;
|
||||
|
||||
let validOCRText = await checkOCRText(ocrText);
|
||||
ocrText = cleanOCRResultLanguageSpecific(ocrText, currentLanguageSetting || 'eng') || '';
|
||||
this.plugin.logger.debug('ocrText (after cleaning)', ocrText);
|
||||
if (!ocrText) return null;
|
||||
|
||||
let validOCRText = await checkOCRText(ocrText, currentLanguageSetting || 'eng');
|
||||
if (!validOCRText) validOCRText = '';
|
||||
if (this.progressManager.isStoppedByUser()) return null;
|
||||
|
||||
this.progressManager.updateProgress('Analyzing image...', 30);
|
||||
if (this.progressManager.isStoppedByUser()) return null;
|
||||
|
||||
const visionLLMResponse = await this.performVisionAnalysis(imageFile, validOCRText);
|
||||
const visionLLMResponse = await this.performVisionAnalysis(imageFile, validOCRText, this.settings.tesseractLanguage);
|
||||
if (!visionLLMResponse || this.progressManager.isStoppedByUser()) return null;
|
||||
|
||||
this.progressManager.updateProgress('Generating notes...', 20);
|
||||
if (this.progressManager.isStoppedByUser()) return null;
|
||||
|
||||
const generatedNotes = await this.generateNotes(validOCRText, visionLLMResponse);
|
||||
const generatedNotes = await this.generateNotes(validOCRText, visionLLMResponse, this.settings);
|
||||
if (!generatedNotes || this.progressManager.isStoppedByUser()) return null;
|
||||
|
||||
this.progressManager.updateProgress('Generating tags...', 20);
|
||||
|
|
@ -253,7 +272,7 @@ export class ScreenshotProcessor {
|
|||
this.progressManager.updateProgress('Saving results...', 20);
|
||||
if (this.progressManager.isStoppedByUser()) return false;
|
||||
|
||||
const tagPath = sanitizeObsidianTag(paths.uniqueName);
|
||||
const tagPath = sanitizeObsidianTag(paths.uniqueName, this.settings);
|
||||
const linkingTag = `#${this.settings.tagPrefix}/${tagPath}`;
|
||||
|
||||
const noteInfo = await this.createObsidianNote(
|
||||
|
|
@ -305,13 +324,18 @@ export class ScreenshotProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
private async performOCR(imageFile: TFile): Promise<string | null> {
|
||||
private async performOCR(imageFile: TFile, language: string | null = null): Promise<string | null> {
|
||||
if (!this.worker) {
|
||||
this.plugin.logger.warn('Tesseract worker not initialized. OCR will not be performed.');
|
||||
new Notice('OCR worker is not ready.');
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((language != null && language != this.tesseractLanguage) || (language == null && this.tesseractLanguage != 'eng')) {
|
||||
this.plugin.logger.warn('Tesseract language mismatch. Reinitializing Tesseract worker.');
|
||||
await this.reinitializeWorker();
|
||||
}
|
||||
|
||||
try {
|
||||
const imageBuffer = await this.app.vault.readBinary(imageFile);
|
||||
if (this.progressManager.isStoppedByUser()) return null;
|
||||
|
|
@ -328,18 +352,27 @@ export class ScreenshotProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
private async performVisionAnalysis(imageFile: TFile, ocrText: string): Promise<string | null> {
|
||||
private async performVisionAnalysis(imageFile: TFile, ocrText: string, language: string | null = null): Promise<string | null> {
|
||||
try {
|
||||
const imageBuffer = await this.app.vault.readBinary(imageFile);
|
||||
const base64Image = arrayBufferToBase64(imageBuffer);
|
||||
|
||||
let languagePromptModifier = '';
|
||||
if (language) {
|
||||
// languagePromptModifier = `\n\nThe response should be in ${language}.`;
|
||||
languagePromptModifier = getLanguagePromptModifierIfIndicated(this.settings, true);
|
||||
}
|
||||
|
||||
let textPayload = getVisionLLMPrompt(this.plugin.dataManager.getConfig()) + languagePromptModifier;
|
||||
this.plugin.logger.debug('Vision LLM prompt:', textPayload);
|
||||
|
||||
const visionPayload = {
|
||||
model: this.settings.visionModelName,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: VISION_LLM_PROMPT },
|
||||
{ type: "text", text: textPayload },
|
||||
{ type: "image_url", image_url: { url: `data:image/png;base64,${base64Image}`, detail: "high" } },
|
||||
],
|
||||
}
|
||||
|
|
@ -364,19 +397,26 @@ export class ScreenshotProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
private async generateNotes(ocrText: string, visionLLMResponse: string): Promise<string | null> {
|
||||
private async generateNotes(ocrText: string, visionLLMResponse: string, settings: VisionRecallPluginSettings): Promise<string | null> {
|
||||
try {
|
||||
let endpointPrompt = `The following text is from a screenshot. Summarize the text and identify key information.`;
|
||||
const config = this.plugin.dataManager.getConfig();
|
||||
const languagePromptModifier = getLanguagePromptModifierIfIndicated(settings, true);
|
||||
|
||||
const visionLLMCategories = Object.keys(visionLLMResponseCategoriesMap);
|
||||
let endpointPrompt = getNotesLLMPrompt(config);
|
||||
|
||||
const visionLLMCategory = visionLLMCategories.find(category => visionLLMResponse.toLowerCase().includes(category));
|
||||
if (config.enableCategoryDetection) {
|
||||
const visionLLMCategories = Object.keys(visionLLMResponseCategoriesMap);
|
||||
|
||||
if (visionLLMCategory) {
|
||||
endpointPrompt = visionLLMResponseCategoriesMap[visionLLMCategory];
|
||||
const visionLLMCategory = visionLLMCategories.find(category => visionLLMResponse.toLowerCase().includes(category));
|
||||
|
||||
if (visionLLMCategory) {
|
||||
endpointPrompt = visionLLMResponseCategoriesMap[visionLLMCategory];
|
||||
}
|
||||
}
|
||||
|
||||
endpointPrompt += `\n\nOCR text:\n${ocrText}\n\nVision analysis:\n${visionLLMResponse}`
|
||||
this.plugin.logger.debug('Generate Notes LLM prompt (without added language modifier, OCR text, or vision analysis that are added later):', endpointPrompt);
|
||||
|
||||
endpointPrompt += `${languagePromptModifier}\n\nOCR text:\n${ocrText}\n\nVision analysis:\n${visionLLMResponse}`
|
||||
|
||||
const endpointPayload = {
|
||||
model: this.settings.endpointLlmModelName,
|
||||
|
|
@ -394,8 +434,8 @@ export class ScreenshotProcessor {
|
|||
);
|
||||
|
||||
this.plugin.logger.debug('Generated notes:', generatedNotes ? generatedNotes.substring(0, 300) + '...' : 'API call failed or no notes generated');
|
||||
return generatedNotes;
|
||||
|
||||
return generatedNotes;
|
||||
} catch (error) {
|
||||
this.plugin.logger.error('Endpoint LLM API error:', error);
|
||||
new Notice('Endpoint LLM API call failed. See console for details.');
|
||||
|
|
@ -460,12 +500,13 @@ export class ScreenshotProcessor {
|
|||
try {
|
||||
const outputNotesFolder = normalizePath(await this.plugin.getFolderFromSettingsKey('outputNotesFolderPath'));
|
||||
|
||||
const noteTitleBase = tagsAndTitle.title || imageFile.basename;
|
||||
const noteTitleBase = sanitizeObsidianTitle(tagsAndTitle.title || imageFile.basename);
|
||||
let noteTitle = `${noteTitleBase} Notes.md`;
|
||||
let notePath = normalizePath(`${outputNotesFolder}/${noteTitle}`);
|
||||
|
||||
let counter = 1;
|
||||
while (this.app.vault.getAbstractFileByPath(notePath) && counter < 100) {
|
||||
this.plugin.logger.debug(`Note title already exists: ${noteTitle}. Incrementing counter and trying again.`);
|
||||
noteTitle = `${noteTitleBase} Notes (${counter}).md`;
|
||||
notePath = normalizePath(`${outputNotesFolder}/${noteTitle}`);
|
||||
counter++;
|
||||
|
|
@ -476,6 +517,9 @@ export class ScreenshotProcessor {
|
|||
return null;
|
||||
}
|
||||
|
||||
const titleWithoutExtension = noteTitle.replace('.md', '').replace(/ /g, '_');
|
||||
const uniqueTag = `#${this.settings.tagPrefix}/${titleWithoutExtension}`;
|
||||
|
||||
// Truncate text if needed
|
||||
const truncatedOcrText = this.settings.truncateOcrText > 0
|
||||
? ocrText.substring(0, this.settings.truncateOcrText)
|
||||
|
|
@ -496,7 +540,7 @@ export class ScreenshotProcessor {
|
|||
// Main note content
|
||||
const noteContent = `# Notes from screenshot: ${noteTitleBase}\n\n${generatedNotes}`;
|
||||
|
||||
const metadataContent = `\n\n---\n*Screenshot filename:* [[${newScreenshotPath}]]\n*${ocrTitle}*:\n\`\`\`\n${truncatedOcrText}...\n\`\`\`\n*${visionTitle}*:\n\`\`\`\n${truncatedVisionLLMResponse}...\n\`\`\`\n\n*Tags:* ${formattedTags}\n\n${linkingTag}\n`;
|
||||
const metadataContent = `\n\n---\n*Screenshot filename:* [[${newScreenshotPath}]]\n*${ocrTitle}*:\n\`\`\`\n${truncatedOcrText}...\n\`\`\`\n*${visionTitle}*:\n\`\`\`\n${truncatedVisionLLMResponse}...\n\`\`\`\n\n*Tags:* ${formattedTags}\n\n${uniqueTag}\n`;
|
||||
|
||||
const finalNoteContent = this.settings.includeMetadataInNote
|
||||
? `${noteContent}\n\n${metadataContent}`
|
||||
|
|
@ -836,56 +880,6 @@ export class ScreenshotProcessor {
|
|||
}
|
||||
}
|
||||
|
||||
async processVaultRootFolder() {
|
||||
if (!this.plugin.settings.intakeFromVaultFolder) {
|
||||
this.plugin.logger.warn('Intake from vault folder is not enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const filenameMustInclude = await this.plugin.getLimitRootFolderIntakeToCSVStrings();
|
||||
if (filenameMustInclude.length === 0) {
|
||||
this.plugin.logger.warn('Must include terms as CSV in the settings to intake from vault root (example: screenshot,IMG)');
|
||||
return;
|
||||
}
|
||||
|
||||
const vaultFolder: TFolder = this.app.vault.getRoot();
|
||||
if (!vaultFolder) {
|
||||
this.plugin.logger.warn('Vault folder not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
const allFiles = vaultFolder.children;
|
||||
|
||||
this.plugin.logger.debug(`Processing ${allFiles.length} files (or folders) in vault root`);
|
||||
|
||||
const files = allFiles.filter(file => {
|
||||
if (!(file instanceof TFile)) return false;
|
||||
const ext = file.extension.toLowerCase();
|
||||
return IMAGE_EXTENSIONS.includes(ext) && filenameMustInclude.some(x => file.name.toLowerCase().includes(x));
|
||||
});
|
||||
|
||||
let addedToQueue = 0;
|
||||
|
||||
if (files.length > 0) {
|
||||
for (const file of files) {
|
||||
if (file instanceof TFile) {
|
||||
if (await shouldProcessImage(this.plugin, file)) {
|
||||
this.plugin.processingQueue.addToQueue(file);
|
||||
addedToQueue++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (addedToQueue > 0) {
|
||||
this.plugin.logger.info(`Added ${addedToQueue} valid screenshots to the queue.`);
|
||||
new Notice(`Added ${addedToQueue} valid screenshots to the queue.`);
|
||||
} else {
|
||||
this.plugin.logger.info('No valid screenshots found in root folder.');
|
||||
new Notice('No valid screenshots found in root folder.');
|
||||
}
|
||||
}
|
||||
|
||||
async processIntakeFolder() {
|
||||
let notice = new Notice("Running screenshot intake...", 0);
|
||||
|
||||
|
|
|
|||
|
|
@ -44,3 +44,14 @@ export function sanitizeFilename(input: string): string {
|
|||
// Replace them with an empty string
|
||||
return normalizePath(input.replace(forbiddenChars, ''))
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a string to be used as an Obsidian title
|
||||
* (may be redundant wrt above function)
|
||||
*
|
||||
* @param title - The title to sanitize
|
||||
* @returns The sanitized title
|
||||
*/
|
||||
export function sanitizeObsidianTitle(title: string): string {
|
||||
return title.replace(/[\\/:]/g, '');
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import { z } from 'zod';
|
|||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
import { callLLMAPI } from '@/services/llm-service';
|
||||
import { VisionRecallPluginSettings } from '@/types/settings-types';
|
||||
import { getLanguagePromptModifierIfIndicated } from '@/lib/languages';
|
||||
|
||||
// Define the Zod schema for tags and title
|
||||
export const TagsSchema = z.object({
|
||||
|
|
@ -120,10 +121,12 @@ export async function generateTagsWithRetries(
|
|||
*/
|
||||
export async function generateTags(generatedNotes: string, settings: VisionRecallPluginSettings): Promise<TagsAndTitle> {
|
||||
try {
|
||||
const languagePromptModifier = getLanguagePromptModifierIfIndicated(settings)
|
||||
const prompt = `
|
||||
Based on the following notes, suggest a concise title and relevant tags.
|
||||
The title should be brief but descriptive.
|
||||
The tags should be single words or short phrases that categorize the content.
|
||||
${languagePromptModifier}
|
||||
|
||||
Notes:
|
||||
${generatedNotes}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { getLanguagesForObsidianSettingsDropdown } from '@/lib/languages';
|
||||
import VisionRecallPlugin from '@/main';
|
||||
import { App, PluginSettingTab, Setting } from 'obsidian';
|
||||
|
||||
|
|
@ -93,6 +94,38 @@ export default class VisionRecallSettingTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
|
||||
new Setting(containerEl).setName('Language (experimental)').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Add language to prompt')
|
||||
.setDesc('Add a line at the end of the prompt to generate the response in the selected language (experimental).')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.addLanguageConvertToPrompt)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.addLanguageConvertToPrompt = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
})
|
||||
);
|
||||
|
||||
const languageOptions = getLanguagesForObsidianSettingsDropdown();
|
||||
|
||||
if (this.plugin.settings.addLanguageConvertToPrompt) {
|
||||
new Setting(containerEl)
|
||||
.setName('Language for OCR and LLM processing')
|
||||
.setDesc('Requested language to use for note generation and screenshot ingestion.')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOptions(languageOptions)
|
||||
.setValue(this.plugin.settings.tesseractLanguage)
|
||||
.onChange(async (value: string) => {
|
||||
this.plugin.settings.tesseractLanguage = value;
|
||||
await this.plugin.saveSettings();
|
||||
this.display();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
new Setting(containerEl).setName('Storage').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
@ -195,32 +228,6 @@ export default class VisionRecallSettingTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
|
||||
new Setting(containerEl).setName('Special circumstances').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Intake from root vault folder')
|
||||
.setDesc('Intake image files from the root vault folder, if the filename includes a term from the list below. \n\n(This is useful if adding images to your vault from Obsidian mobile app.)')
|
||||
.addToggle(toggle => toggle
|
||||
.setValue(this.plugin.settings.intakeFromVaultFolder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.intakeFromVaultFolder = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Limit intake to comma-separated list of terms')
|
||||
.setDesc('Only image files with a filename that includes a term from the list will be processed. (Required for intake from root vault folder functionality.)')
|
||||
.addText(text => text
|
||||
.setPlaceholder('e.g., screenshot, IMG, etc.')
|
||||
.setValue(this.plugin.settings.limitIntakeToCSV)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.limitIntakeToCSV = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl).setName('Output note').setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ export const useQueueStore = create<QueueStore>((set, get) => ({
|
|||
total: state.status.total + files.length
|
||||
}
|
||||
})),
|
||||
clearQueue: () => set({ status: DEFAULT_STATUS }),
|
||||
clearQueue: () => set({ status: { ...DEFAULT_STATUS, queue: [] } }),
|
||||
setMessage: (message) => set((state) => ({
|
||||
status: { ...state.status, message }
|
||||
})),
|
||||
|
|
|
|||
107
src/styles/components/select-images-modal.scss
Normal file
107
src/styles/components/select-images-modal.scss
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
.vr-select-images-modal {
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
background: var(--background-secondary);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.gallery-image-container {
|
||||
position: relative;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gallery-image-container img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gallery-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-image-container:hover .gallery-overlay {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.gallery-overlay-content-display {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
opacity: 1;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gallery-button {
|
||||
background: var(--interactive-accent);
|
||||
color: var(--text-on-accent);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.gallery-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.gallery-info {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.gallery-filename {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.gallery-date {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.gallery-tags {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@
|
|||
@use './components/processing-queue.scss';
|
||||
@use './components/tag-combobox.scss';
|
||||
@use './components/delete-confirmation-modal.scss';
|
||||
@use './components/select-images-modal.scss';
|
||||
@tailwind base;
|
||||
|
||||
@layer base {
|
||||
|
|
@ -461,3 +462,13 @@
|
|||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.vr-select-images-modal {
|
||||
--dialog-width: 80vw;
|
||||
--dialog-height: 90vh;
|
||||
}
|
||||
|
||||
.vr-doc-viewer-modal {
|
||||
--dialog-width: 80vw;
|
||||
--dialog-height: 90vh;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { VISION_LLM_PROMPT, NOTES_LLM_PROMPT } from '@/services/llm-service';
|
||||
|
||||
export interface Config {
|
||||
enableAutoIntakeFolderProcessing?: boolean;
|
||||
|
||||
|
|
@ -10,6 +12,12 @@ export interface Config {
|
|||
|
||||
experimentalFeatures?: string[];
|
||||
|
||||
visionLLMPrompt?: string;
|
||||
|
||||
enableCategoryDetection?: boolean; // this is rudimentarily implemented, so defaulting to false
|
||||
|
||||
notesLLMPrompt?: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
|
|
@ -18,5 +26,8 @@ export const DefaultConfig: Config = {
|
|||
enablePeriodicIntakeFolderProcessing: false,
|
||||
intakeFolderPollingInterval: 300, // 300 seconds, or 5 minutes
|
||||
defaultMinimizedProgressDisplay: false, // Currently not functional
|
||||
experimentalFeatures: []
|
||||
experimentalFeatures: [],
|
||||
visionLLMPrompt: VISION_LLM_PROMPT,
|
||||
enableCategoryDetection: false,
|
||||
notesLLMPrompt: NOTES_LLM_PROMPT,
|
||||
};
|
||||
|
|
@ -7,6 +7,9 @@ export interface VisionRecallPluginSettings {
|
|||
visionModelName: string;
|
||||
endpointLlmModelName: string;
|
||||
|
||||
addLanguageConvertToPrompt: boolean;
|
||||
tesseractLanguage: string;
|
||||
|
||||
useParentFolder: boolean;
|
||||
parentFolderPath: string;
|
||||
|
||||
|
|
@ -16,9 +19,6 @@ export interface VisionRecallPluginSettings {
|
|||
screenshotIntakeFolderPath: string;
|
||||
outputNotesFolderPath: string;
|
||||
|
||||
intakeFromVaultFolder: boolean;
|
||||
limitIntakeToCSV: string;
|
||||
|
||||
maxTokens: number;
|
||||
truncateOcrText: number;
|
||||
truncateVisionLLMResponse: number;
|
||||
|
|
@ -44,6 +44,9 @@ export const DEFAULT_SETTINGS: VisionRecallPluginSettings = {
|
|||
visionModelName: 'gpt-4o-mini',
|
||||
endpointLlmModelName: 'gpt-4o-mini',
|
||||
|
||||
addLanguageConvertToPrompt: false,
|
||||
tesseractLanguage: 'eng',
|
||||
|
||||
useParentFolder: true,
|
||||
parentFolderPath: 'VisionRecall',
|
||||
|
||||
|
|
@ -54,9 +57,6 @@ export const DEFAULT_SETTINGS: VisionRecallPluginSettings = {
|
|||
screenshotIntakeFolderPath: 'Intake',
|
||||
outputNotesFolderPath: 'Notes',
|
||||
|
||||
intakeFromVaultFolder: false,
|
||||
limitIntakeToCSV: '',
|
||||
|
||||
maxTokens: 500,
|
||||
truncateOcrText: 500,
|
||||
truncateVisionLLMResponse: 500,
|
||||
|
|
|
|||
6
src/types/shared-types.ts
Normal file
6
src/types/shared-types.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { TFolder } from 'obsidian';
|
||||
|
||||
export type FolderWithPath = {
|
||||
folder: TFolder;
|
||||
path: string;
|
||||
}
|
||||
|
|
@ -7,9 +7,9 @@ import ListView from './ListView';
|
|||
import GalleryView from './GalleryView';
|
||||
import VisionRecallPlugin from '@/main';
|
||||
import { DataManager } from '@/data/DataManager';
|
||||
import { PLUGIN_ICON, PLUGIN_NAME } from '@/constants';
|
||||
import { PLUGIN_ICON, PLUGIN_NAME, STORAGE_KEYS } from '@/constants';
|
||||
import { StrictMode } from 'react';
|
||||
import { ProcessingDisplay } from '@/components/ProcessingDisplay';
|
||||
// import { ProcessingDisplay } from '@/components/ProcessingDisplay';
|
||||
|
||||
export const MAIN_VIEW_TYPE = 'vision-recall-view';
|
||||
|
||||
|
|
@ -76,7 +76,7 @@ const ViewContainer = ({ initialMetadata }) => {
|
|||
|
||||
const [viewMode, setViewMode] = React.useState<'list' | 'gallery'>(() => {
|
||||
try {
|
||||
const saved = plugin.app.loadLocalStorage('vision-recall-view-mode');
|
||||
const saved = plugin.app.loadLocalStorage(STORAGE_KEYS.VIEW_MODE);
|
||||
return saved === 'list' ? 'list' : 'gallery';
|
||||
} catch {
|
||||
// return 'list';
|
||||
|
|
@ -87,7 +87,7 @@ const ViewContainer = ({ initialMetadata }) => {
|
|||
const handleViewModeChange = (mode: 'list' | 'gallery') => {
|
||||
setViewMode(mode);
|
||||
try {
|
||||
plugin.app.saveLocalStorage('vision-recall-view-mode', mode);
|
||||
plugin.app.saveLocalStorage(STORAGE_KEYS.VIEW_MODE, mode);
|
||||
} catch (e) {
|
||||
plugin.logger.error('Failed to save view mode preference:', e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,11 @@
|
|||
"1.0.7": "1.8.3",
|
||||
"1.0.8": "1.8.3",
|
||||
"1.0.9": "1.8.3",
|
||||
"1.0.10": "1.8.3"
|
||||
"1.0.10": "1.8.3",
|
||||
"1.0.11": "1.8.3",
|
||||
"1.0.12": "1.8.3",
|
||||
"1.0.13": "1.8.3",
|
||||
"1.0.14": "1.8.3",
|
||||
"1.1.0": "1.8.3",
|
||||
"1.2.0": "1.8.3"
|
||||
}
|
||||
Loading…
Reference in a new issue