Compare commits

...

12 commits
1.0.10 ... main

Author SHA1 Message Date
Travis Van Nimwegen
6b99aafa81
Update README.md to remove badge for hits counter, as the service seems to have been shut down 2025-04-01 16:38:25 -04:00
Travis Van Nimwegen
ea199c4d50
Update README.md with Discord badge (join the community! :)) 2025-03-21 23:55:13 -04:00
Travis Van Nimwegen
50a079968a
Update README.md 2025-03-20 21:45:01 -04:00
Travis Van Nimwegen
773cb610e6 Enhance configuration options
- Introduced new configuration options for vision and notes LLM prompts
- Added category detection feature toggle in ConfigModal
- Updated ScreenshotProcessor to utilize new prompt configurations
- Cleaned up HelpModal by removing outdated forum links
2025-03-20 17:22:57 -04:00
Travis Van Nimwegen
317d3c2f6f Implement language support features
- Added language support functionality, including validation
- Introduced new language-related utility functions in languages.ts
- Enhanced OCR validation to consider expected language
- Updated settings page to allow users to select a language for OCR and LLM processing
- Integrated language settings into tag generation and note creation processes
2025-03-20 01:09:31 -04:00
Travis Van Nimwegen
a183e1e12c Update version to 1.0.14 and add sanitizeObsidianTitle function
- Bumped version in manifest.json and package.json to 1.0.14
- Added sanitizeObsidianTitle function to shared-functions.ts for title sanitization
- Updated ScreenshotProcessor to utilize the new sanitizeObsidianTitle function
2025-03-13 18:31:30 -04:00
Travis Van Nimwegen
e88f1aa246 Add help modal and update UI with support resources
- Added new HelpModal with links to wiki, Discord, and forum
- Updated MainViewHeader to include help button with HelpCircle icon
- Added external link support in HelpModal
- Version bump to 1.0.13
2025-03-09 23:31:13 -04:00
Travis Van Nimwegen
9d8a2c6cfa Add LM Studio documentation and UI improvements
- Added comprehensive LM Studio setup documentation
- Updated DocViewerModal to support new LM Studio documentation
- Improved accessibility labels and wording in SelectImagesModal and TagCombobox
- Commented out minimized progress display option in ConfigModal (currently not in use but may be in future)
- Added modal size styling for documentation viewer
- Version bump to 1.0.12
2025-03-06 16:44:56 -05:00
Travis Van Nimwegen
5b8c5f9012 Add view mode storage key and update queue clearing behavior
- Added VIEW_MODE storage key to constants
- Modified queue clearing to preserve queue array in queueStore
- Updated MainView to use new storage key constant for view mode persistence

(Not issuing an update based on just this)
2025-03-06 16:44:56 -05:00
Travis Van Nimwegen
b5b86dc5ac
Update README.md 2025-03-05 19:11:28 -05:00
Travis Van Nimwegen
63a28f1536 Refactor image intake and add new helper service
- Removed vault root folder intake settings and related functionality
- Created new HelperService with methods for retrieving folders and images
- Added SelectImagesModal for interactive image selection from vault folders
- Updated MainViewHeader to use new helper service for image selection
- Simplified image intake logic in main plugin file
- Added new styles for select images modal
- Version bump to 1.0.11
2025-03-05 16:57:18 -05:00
Travis Van Nimwegen
2ef08c3dd5
Update README.md 2025-03-04 20:59:38 -05:00
30 changed files with 1331 additions and 190 deletions

View file

@ -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.
![Vision Recall Obsidian Plugin Demo](./demo.gif)
### 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)

View file

@ -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",

View file

@ -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": {

View file

@ -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>

View file

@ -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">

View file

@ -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);

View 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();
}
}

View 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();
}
}

View file

@ -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()}

View file

@ -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>

View file

@ -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
View 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
View 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;
}

View file

@ -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
}
}

View file

@ -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;
}

View file

@ -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}`);

View 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();
}
}

View file

@ -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,

View file

@ -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);

View file

@ -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, '');
}

View file

@ -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}

View file

@ -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)

View file

@ -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 }
})),

View 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;
}
}

View file

@ -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;
}

View file

@ -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,
};

View file

@ -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,

View file

@ -0,0 +1,6 @@
import { TFolder } from 'obsidian';
export type FolderWithPath = {
folder: TFolder;
path: string;
}

View file

@ -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);
}

View file

@ -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"
}