Compare commits

...

10 commits
1.0.11 ... 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
26 changed files with 849 additions and 66 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>
@ -77,3 +79,8 @@
![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.11",
"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.11",
"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[];
@ -165,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
@ -177,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>
@ -247,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();
@ -255,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,7 @@ 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 {
@ -17,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 }) => {
@ -73,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'
@ -94,7 +105,9 @@ const DocViewerForm: React.FC<DocViewerFormProps> = ({ onClose, app, plugin }) =
</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>
@ -111,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

@ -123,7 +123,7 @@ const SelectImagesForm: React.FC<SelectImagesModalProps> = ({ plugin, folder, on
className={cn('gallery-image cursor-pointer', {
'opacity-50': selectedImages.includes(image),
})}
aria-label={`Select screenshot: ${image.name}`}
aria-label={selectedImages.includes(image) ? `Deselect screenshot` : `Select screenshot`}
data-tooltip-position="top"
/>

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

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

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)

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

@ -467,3 +467,8 @@
--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;
@ -41,6 +44,9 @@ export const DEFAULT_SETTINGS: VisionRecallPluginSettings = {
visionModelName: 'gpt-4o-mini',
endpointLlmModelName: 'gpt-4o-mini',
addLanguageConvertToPrompt: false,
tesseractLanguage: 'eng',
useParentFolder: true,
parentFolderPath: 'VisionRecall',

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

@ -10,5 +10,10 @@
"1.0.8": "1.8.3",
"1.0.9": "1.8.3",
"1.0.10": "1.8.3",
"1.0.11": "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"
}