Refactor and modernize project structure and styling

- Migrated CSS files to SCSS with more modular and scoped styling
- Added new DeleteConfirmationModal for consistent deletion UX
- Updated file and folder handling to use Obsidian's native methods
- Improved local storage interactions through Obsidian app methods
- Bumped version to 1.0.4 and updated minimum app version
- Added new path alias and TypeScript configuration updates

And all other points outlined in the PR review
This commit is contained in:
Travis Van Nimwegen 2025-02-26 04:32:58 -05:00
parent 1705c01c15
commit a417a7cb21
45 changed files with 6633 additions and 6182 deletions

View file

@ -1,10 +1,10 @@
{
"id": "vision-recall",
"name": "Vision Recall",
"version": "1.0.3",
"minAppVersion": "0.15.0",
"description": "Transform screenshots into searchable notes using AI vision and text analysis.",
"author": "Travis Van Nimwegen",
"authorUrl": "https://travis.engineer",
"isDesktopOnly": true
}
"id": "vision-recall",
"name": "Vision Recall",
"version": "1.0.4",
"minAppVersion": "1.8.3",
"description": "Transform screenshots into searchable notes using AI vision and text analysis.",
"author": "Travis Van Nimwegen",
"authorUrl": "https://travis.engineer",
"isDesktopOnly": true
}

11229
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "obsidian-vision-recall",
"version": "1.0.3",
"version": "1.0.4",
"description": "Transform screenshots into searchable Obsidian notes using AI vision and text analysis.",
"main": "main.js",
"scripts": {
@ -27,6 +27,7 @@
"esbuild": "0.17.3",
"obsidian": "latest",
"postcss": "^8.5.1",
"sass": "^1.85.1",
"tailwindcss": "^3.4.17",
"tslib": "2.4.0",
"typescript": "^5.7.3",

View file

@ -19,7 +19,7 @@ export const InitializeFolders = ({ foldersInitialized, setFoldersInitialized }:
if (success) {
setFoldersInitialized(true);
localStorage.setItem(STORAGE_KEYS.INITIALIZE_FOLDERS, 'true');
plugin.app.saveLocalStorage(STORAGE_KEYS.INITIALIZE_FOLDERS, 'true');
}
}

View file

@ -19,7 +19,7 @@ const ConfigModalContent = ({
useEffect(() => {
if (initialized) return;
const loadConfig = async () => {
const currentConfig = await dataManager.getConfig();
const currentConfig = dataManager.getConfig();
setConfig(currentConfig);
setInitialized(true);
};
@ -42,7 +42,7 @@ const ConfigModalContent = ({
}
return (
<div className="config-modal-container">
<div className="vr config-modal-container">
<form onSubmit={handleSubmit} className="config-modal-form">
<div className="config-field">

View file

@ -39,7 +39,7 @@ const DebugOperationsView = ({
};
return (
<div className="config-modal-container">
<div className="vr config-modal-container">
<div className="debug-operations-content">
<button
className="mod-warning cursor-pointer"

View file

@ -0,0 +1,68 @@
import { App, Modal } from 'obsidian';
import React from 'react';
import { createRoot } from 'react-dom/client';
import VisionRecallPlugin from '@/main';
interface DeleteConfirmationModalProps {
message: string;
onConfirm: () => void;
onClose: () => void;
}
const DeleteConfirmationForm: React.FC<DeleteConfirmationModalProps> = ({ message, onConfirm, onClose }) => {
return (
<div className="vr vr-delete-confirmation-modal">
<div className="message">{message}</div>
<div className="button-group">
<button type="button" onClick={onClose}>Close</button>
<button type="button" onClick={onConfirm}>Confirm</button>
</div>
</div>
);
};
export class DeleteConfirmationModal extends Modal {
private message: string;
private handleConfirm: () => Promise<void>;
private handleClose: () => void;
private plugin: VisionRecallPlugin;
constructor(
app: App,
plugin: VisionRecallPlugin,
message: string,
onConfirm: () => Promise<void>,
onClose?: () => void
) {
super(app);
this.message = message;
this.handleConfirm = onConfirm;
this.handleClose = onClose;
this.plugin = plugin;
}
async handleConfirmWrapper() {
await this.handleConfirm();
this.close();
}
onOpen() {
const { contentEl, titleEl } = this;
titleEl.setText('Confirm deletion');
const root = createRoot(contentEl);
root.render(
<DeleteConfirmationForm
message={this.message}
onConfirm={() => this.handleConfirmWrapper()}
onClose={() => this.handleClose()}
/>
);
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -1,38 +1,48 @@
import { App, MarkdownRenderer, Modal } from 'obsidian';
import { App, MarkdownRenderer, Modal, requestUrl } from 'obsidian';
import React, { useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import VisionRecallPlugin from '@/main';
interface DocViewerFormProps {
docPath: string;
onClose: () => void;
app: App;
plugin: VisionRecallPlugin;
}
const DOC_PATHS = {
OLLAMA_SETUP: 'ollama-setup.md',
REFERENCE_GUIDE: 'reference.md',
const DOC_LOCATIONS = {
'ollama-setup': 'https://raw.githubusercontent.com/travisvn/obsidian-vision-recall/main/docs/ollama-setup.md',
'reference-guide': 'https://raw.githubusercontent.com/travisvn/obsidian-vision-recall/main/docs/reference.md',
}
const DocViewerForm: React.FC<DocViewerFormProps> = ({ docPath, onClose, app, plugin }) => {
const DocViewerForm: React.FC<DocViewerFormProps> = ({ onClose, app, plugin }) => {
const containerRef = useRef<HTMLDivElement>(null);
const [selectedDoc, setSelectedDoc] = useState<string>('ollama-setup');
const [content, setContent] = useState<string>('');
const [error, setError] = useState<string>('');
useEffect(() => {
const loadDocument = async () => {
try {
const fileContent = await app.vault.adapter.read(docPath);
setContent(fileContent);
} catch (err) {
console.error('Error loading document:', err);
setError('Failed to load document');
const fetchContent = async (url: string) => {
try {
// const response = await fetch(url);
const response = await requestUrl(url);
if (response.status !== 200) {
throw new Error(`HTTP error! status: ${response.status}`);
}
};
const text = response.text;
return text;
} catch (error) {
console.error('Error fetching content:', error);
setError('Failed to fetch content');
return '';
}
}
loadDocument();
}, [docPath, app.vault]);
useEffect(() => {
const fetchContentEffect = async () => {
const content = await fetchContent(DOC_LOCATIONS[selectedDoc]);
setContent(content);
}
fetchContentEffect();
}, [selectedDoc]);
useEffect(() => {
if (!containerRef.current || !content) return;
@ -46,15 +56,13 @@ const DocViewerForm: React.FC<DocViewerFormProps> = ({ docPath, onClose, app, pl
}, [content, app, plugin]);
return (
<div className="doc-viewer-modal">
<div className="vr doc-viewer-modal">
<div className='flex flex-row gap-2 justify-center items-center'>
<button
type='button'
className='cursor-pointer'
onClick={async () => {
const newDocPath = `${plugin.manifest.dir}/docs/${DOC_PATHS.OLLAMA_SETUP}`;
const fileContent = await app.vault.adapter.read(newDocPath);
setContent(fileContent);
setSelectedDoc('ollama-setup');
}}
>
Ollama setup
@ -63,9 +71,7 @@ const DocViewerForm: React.FC<DocViewerFormProps> = ({ docPath, onClose, app, pl
type='button'
className='cursor-pointer'
onClick={async () => {
const newDocPath = `${plugin.manifest.dir}/docs/${DOC_PATHS.REFERENCE_GUIDE}`;
const fileContent = await app.vault.adapter.read(newDocPath);
setContent(fileContent);
setSelectedDoc('reference-guide');
}}
>
Reference guide
@ -81,13 +87,11 @@ const DocViewerForm: React.FC<DocViewerFormProps> = ({ docPath, onClose, app, pl
};
export class DocViewerModal extends Modal {
private docPath: string;
private plugin: VisionRecallPlugin;
constructor(app: App, plugin: VisionRecallPlugin, docName: string) {
constructor(app: App, plugin: VisionRecallPlugin) {
super(app);
this.plugin = plugin;
this.docPath = `${plugin.manifest.dir}/docs/${docName}.md`;
}
onOpen() {
@ -97,7 +101,6 @@ export class DocViewerModal extends Modal {
root.render(
<DocViewerForm
docPath={this.docPath}
onClose={() => this.close()}
app={this.app}
plugin={this.plugin}

View file

@ -28,7 +28,7 @@ const EditMetadataForm: React.FC<EditMetadataModalProps> = ({ metadata, onSave,
};
return (
<div className="edit-metadata-modal">
<div className="vr vr-edit-metadata-modal">
<form onSubmit={handleSubmit}>
<div className="form-group">

View file

@ -66,7 +66,7 @@ const FileUploadView = (props: FileUploadViewProps) => {
};
return (
<div className="file-upload-modal-content">
<div className="vr file-upload-modal-content">
<div className="file-upload-container">
<input
type="file"

View file

@ -23,7 +23,7 @@ const QueueControls: React.FC = () => {
};
return (
<div className="queue-controls flex flex-row items-center gap-2 mb-4 mx-auto justify-center">
<div className="vr queue-controls flex flex-row items-center gap-2 mb-4 mx-auto justify-center">
{shouldShowControls ? (
<>
{status.isProcessing ? (
@ -77,8 +77,8 @@ const QueueStatus: React.FC = () => {
const { status } = useQueueStore();
return (
<div className="queue-status mb-4 mx-auto justify-center">
<div className="text-sm font-medium">
<div className="vr queue-status mb-4 mx-auto justify-center w-full">
<div className="text-sm font-medium text-center">
{status.isProcessing ? (
status.isPaused ? (
<span className="text-yellow-500"> Processing paused</span>
@ -104,7 +104,7 @@ const ProcessingQueueView: React.FC = () => {
}, []);
return (
<div className="processing-queue-modal w-full">
<div className="vr processing-queue-modal w-full">
<div className="queue-stats flex flex-row items-center justify-between gap-1 w-full mb-4 p-2 bg-secondary rounded">
<div>Total: {status.queue.length}</div>

View file

@ -21,7 +21,7 @@ export class ScreenshotModal extends Modal {
contentEl.addClass('screenshot-modal-content');
// Create a link element
const linkEl = contentEl.createEl('a', { cls: 'screenshot-modal-link' });
const linkEl = contentEl.createEl('a', { cls: 'vr screenshot-modal-link' });
// Set the link to open the image in Obsidian
const file = this.app.vault.getAbstractFileByPath(this.imagePath);

View file

@ -38,7 +38,7 @@ const TestConfigView: React.FC<TestConfigModalProps> = ({ dataManager, plugin, o
};
return (
<div className='flex flex-col gap-4 items-center justify-center'>
<div className='flex flex-col gap-4 items-center justify-center vision-recall-styling'>
<details className='w-full'>
<summary
aria-label='Expand LLM configuration'

View file

@ -11,7 +11,7 @@ interface ViewMetadataModalProps {
const ViewMetadataForm: React.FC<ViewMetadataModalProps> = ({ metadata, onClose }) => {
return (
<div className="view-metadata-modal select-text">
<div className="vr view-metadata-modal select-text">
<div className="metadata-section">
<h4>File information</h4>

View file

@ -8,8 +8,7 @@ interface DataContextType {
addEntry: (entry: UserData) => Promise<void>;
removeEntry: (id: string) => Promise<void>;
refreshEntries: () => Promise<void>;
getConfig: () => Promise<Config>;
getConfigSynchronous: () => Config;
getConfig: () => Config;
setConfig: (config: Config) => Promise<void>;
getAvailableTags: () => Set<string>;
setAvailableTags: (tags: Set<string>) => Promise<void>;
@ -64,12 +63,8 @@ export const DataProvider: React.FC<{ dataManager: DataManager; children: React.
await refreshEntries(); // Ensure immediate UI update
};
const getConfig = async () => {
return await dataManager.getConfig();
};
const getConfigSynchronous = () => {
return dataManager.getConfigSynchronous();
const getConfig = () => {
return dataManager.getConfig();
};
const setConfig = async (config: Config) => {
@ -100,7 +95,6 @@ export const DataProvider: React.FC<{ dataManager: DataManager; children: React.
removeEntry,
refreshEntries,
getConfig,
getConfigSynchronous,
setConfig,
getAvailableTags,
setAvailableTags,

View file

@ -1,7 +1,7 @@
import VisionRecallPlugin from '@/main';
import { Low, Memory } from 'lowdb';
import { DateTime } from 'luxon';
import { Events, normalizePath } from 'obsidian';
import { Events, normalizePath, TFile } from 'obsidian';
import { Config, DefaultConfig } from '@/types/config-types';
import { ProcessedFileRecord } from '@/types/processing-types';
import { customParse, customStringify } from '@/lib/json-utils';
@ -82,15 +82,11 @@ const DEFAULT_STORED_DATA: StoredData = {
export class DataManager extends Events {
private db: Low<StoredData>;
plugin: VisionRecallPlugin;
private pollIntervalId: number | null = null;
constructor(plugin: VisionRecallPlugin) {
super();
this.plugin = plugin;
this.db = new Low(new Memory(), DEFAULT_STORED_DATA);
// Cleanup interval when plugin unloads
plugin.register(() => this.stopIntakeDirectoryPolling());
}
// Add a helper to check for valid config in the loaded data.
@ -164,6 +160,7 @@ export class DataManager extends Events {
/** Initialize and load user data, preserving other settings */
/** Keeping for posterity sake as the application needs change */
async initOLD() {
try {
const savedData = await this.plugin.loadData();
@ -420,7 +417,7 @@ export class DataManager extends Events {
// Only create a new backup if one doesn't exist for today
if (!this.plugin.app.vault.getAbstractFileByPath(newLocationForExistingFile)) {
const currentFileContent = await this.plugin.app.vault.read(file);
const currentFileContent = await this.plugin.app.vault.cachedRead(file);
await this.plugin.app.vault.create(newLocationForExistingFile, currentFileContent);
this.plugin.logger.info("created new backup file", newLocationForExistingFile);
@ -446,7 +443,9 @@ export class DataManager extends Events {
}
}
await this.plugin.app.vault.modify(file, dataStr);
this.plugin.app.vault.process(file, (data: string) => {
return dataStr;
});
this.plugin.logger.info("modified existing file", file.path);
}
}
@ -477,14 +476,6 @@ export class DataManager extends Events {
async importUserData(jsonData: string) {
try {
const parsedData = customParse(jsonData) as StoredData;
// Ensure the imported data has the right structure
// if (!parsedData || typeof parsedData !== 'object' || !Array.isArray(parsedData.userData.list) || typeof parsedData.userData.map !== 'object') {
// throw new Error('Invalid userData format.');
// }
// Merge new user data with the existing database
// this.db.data.userData = parsedData.userData;
this.db.data = parsedData;
await this.persist();
} catch (error) {
@ -493,23 +484,10 @@ export class DataManager extends Events {
}
}
async getConfig() {
getConfig() {
return this.db.data.config || {};
}
getConfigSynchronous() {
return this.db.data.config || {};
}
async updateConfigOLD(updatedConfig: Partial<Config>) {
this.db.data.config = {
...this.db.data.config,
...updatedConfig
};
await this.persist();
this.trigger('config-updated'); // Emit config-updated event
}
getProcessedFileRecords() {
return this.db.data.processedFileRecords;
}
@ -573,30 +551,8 @@ export class DataManager extends Events {
await this.persist();
}
async startIntakeDirectoryPolling() {
if (!this.db.data.config.enableIntakeFolderPolling) return;
// consider intakeFolderPollingInterval as seconds (so convert to ms)
const interval = (this.db.data.config.intakeFolderPollingInterval || DefaultConfig.intakeFolderPollingInterval) * 1000;
if (interval < 30000) {
this.plugin.logger.warn('DataManager: Intake folder polling interval is less than 30 seconds. This is not recommended.');
return;
}
this.pollIntervalId = window.setInterval(
() => this.plugin.screenshotProcessor.processIntakeFolderAuto(),
interval
);
}
stopIntakeDirectoryPolling() {
if (this.pollIntervalId) {
window.clearInterval(this.pollIntervalId);
this.pollIntervalId = null;
}
}
/** Check for unprocessed images and trigger processing */
/** Keeping for posterity sake as the application needs change */
private async checkForUnprocessedImages() {
const periodicProcessingEnabled = await this.plugin.periodicProcessingEnabled();
if (!periodicProcessingEnabled) return;
@ -678,24 +634,24 @@ export class DataManager extends Events {
// Check if screenshot file exists
if (entry.screenshotStoragePath) {
const screenshotExists = await this.plugin.app.vault.adapter.exists(entry.screenshotStoragePath);
if (!screenshotExists) {
const screenshotExists = this.plugin.app.vault.getAbstractFileByPath(entry.screenshotStoragePath);
if (screenshotExists instanceof TFile) {
shouldRemove = true;
}
}
// Check if note file exists
if (entry.notePath) {
const noteExists = await this.plugin.app.vault.adapter.exists(entry.notePath);
if (!noteExists) {
const noteExists = this.plugin.app.vault.getAbstractFileByPath(entry.notePath);
if (noteExists instanceof TFile) {
shouldRemove = true;
}
}
// Check if metadata file exists
if (entry.metadataPath) {
const metadataExists = await this.plugin.app.vault.adapter.exists(entry.metadataPath);
if (!metadataExists) {
const metadataExists = this.plugin.app.vault.getAbstractFileByPath(entry.metadataPath);
if (metadataExists instanceof TFile) {
shouldRemove = true;
}
}

View file

@ -58,7 +58,7 @@ export class StatusBarManager {
}
this.setStatusBarIcon(queueIndicator, icon);
setTooltip(this.statusBarProcessingQueueEl, 'Processing Queue', { placement: 'top' });
setTooltip(this.statusBarProcessingQueueEl, 'Processing queue', { placement: 'top' });
queueIndicator.onclick = () => this.plugin.openProcessingQueueModal();
if (isProcessing && !isPaused) {

View file

@ -1,4 +1,4 @@
import { TFile, normalizePath, FileSystemAdapter } from 'obsidian';
import { TFile, normalizePath, Vault } from 'obsidian';
import { base64ToExtension } from './encode';
import VisionRecallPlugin from '@/main';
@ -18,21 +18,21 @@ export async function saveBase64ImageInVault(plugin: VisionRecallPlugin, base64:
const buffer = Buffer.from(base64Data, 'base64');
// Get the Obsidian filesystem
const adapter = plugin.app.vault.adapter;
if (!(adapter instanceof FileSystemAdapter)) {
const vault = plugin.app.vault;
if (!(vault instanceof Vault)) {
plugin.logger.error("This Obsidian instance does not support file writing.");
return null;
}
// Ensure the folder exists
const folderExists = await adapter.exists(folderPath);
const folderExists = vault.getFolderByPath(folderPath);
if (!folderExists) {
// await adapter.mkdir(folderPath);
return null;
}
// Write the file
await adapter.writeBinary(filePath, buffer);
await vault.createBinary(filePath, buffer);
// Return the saved file
const file = plugin.app.vault.getAbstractFileByPath(filePath);

View file

@ -4,7 +4,7 @@ import { PLUGIN_ICON, PLUGIN_NAME } from '@/constants';
import ScreenshotKBSettingTab from '@/settings/SettingsPage';
import { VisionRecallPluginSettings, DEFAULT_SETTINGS } from '@/types/settings-types';
import { ScreenshotProcessor } from '@/services/screenshot-processor';
import "@/styles/global.css";
import "@/styles/global.scss";
import { DataManager, StoredData } from './data/DataManager';
import { PluginLogger } from '@/lib/Logger';
import { shouldProcessImage } from '@/lib/image-utils';
@ -15,6 +15,7 @@ import { useQueueStore } from '@/stores/queueStore';
import { StatusBarManager } from './lib/StatusBarManager';
import { debounce } from 'obsidian';
import { customParse } from './lib/json-utils';
import { DefaultConfig } from './types/config-types';
export default class VisionRecallPlugin extends Plugin {
settings: VisionRecallPluginSettings;
@ -37,12 +38,11 @@ export default class VisionRecallPlugin extends Plugin {
this.processingQueue = new ProcessingQueue(this, useQueueStore);
this.statusBarManager = new StatusBarManager(this);
this.initializeProcessing();
this.initializeUI();
this.registerEventListeners();
await this.registerEventListeners();
this.registerCommands();
this.registerProtocolHandler();
await this.startBackgroundProcesses();
await this.loadScreenshotMetadata();
this.logger.info('Plugin loaded successfully');
} catch (error) {
@ -51,15 +51,10 @@ export default class VisionRecallPlugin extends Plugin {
}
}
// ... (rest of the methods, as before, BUT WITH CHANGES BELOW) ...
private async initializeDataManagement() {
await this.dataManager.init();
}
private initializeProcessing() {
// Nothing specific to do here now.
}
private initializeUI() {
this.statusBarManager.initialize();
@ -75,8 +70,7 @@ export default class VisionRecallPlugin extends Plugin {
});
}
private registerEventListeners() {
private async registerEventListeners() {
// Debounce the file creation handler
this.debouncedOnCreateHandler = debounce(this.onFileCreated.bind(this), 500, true);
@ -84,6 +78,25 @@ export default class VisionRecallPlugin extends Plugin {
this.app.vault.on('create', this.debouncedOnCreateHandler)
);
if (await this.periodicProcessingEnabled()) {
const config = this.dataManager.getConfig();
// consider intakeFolderPollingInterval as seconds (so convert to ms)
const interval = (config.intakeFolderPollingInterval || DefaultConfig.intakeFolderPollingInterval) * 1000;
if (interval < 30000) {
this.logger.warn('DataManager: Intake folder polling interval is less than 30 seconds. This is not recommended.');
return;
}
this.registerEvent(
window.setInterval(
() => this.screenshotProcessor.processIntakeFolderAuto(),
interval
)
)
}
this.dataManager.on('unprocessed-images-found', async () => {
await this.screenshotProcessor.processIntakeFolderAuto();
});
@ -93,7 +106,7 @@ export default class VisionRecallPlugin extends Plugin {
this.addCommand({
id: 'open-main-view',
name: 'Open main view',
callback: async () => this.activateView()
callback: async () => this.openView()
});
this.addCommand({
@ -164,31 +177,18 @@ export default class VisionRecallPlugin extends Plugin {
});
}
private async startBackgroundProcesses() {
await this.loadScreenshotMetadata();
this.registerInterval(
window.setInterval(() => this.logger.debug('Interval check'), 5 * 60 * 1000)
);
if (await this.periodicProcessingEnabled()) {
await this.dataManager.startIntakeDirectoryPolling();
}
}
openProcessingQueueModal() {
const modal = new ProcessingQueueModal({ app: this.app, plugin: this });
modal.open();
}
async autoProcessingEnabled(): Promise<boolean> {
const config = await this.dataManager.getConfig();
const config = this.dataManager.getConfig();
return config.enableAutoIntakeFolderProcessing;
}
async periodicProcessingEnabled(): Promise<boolean> {
const config = await this.dataManager.getConfig();
const config = this.dataManager.getConfig();
return config.enablePeriodicIntakeFolderProcessing;
}
@ -209,7 +209,6 @@ export default class VisionRecallPlugin extends Plugin {
}
}
onunload() {
if (this.screenshotProcessor) {
this.screenshotProcessor.terminateWorker();
@ -233,33 +232,6 @@ export default class VisionRecallPlugin extends Plugin {
});
}
async activateView() {
this.app.workspace.detachLeavesOfType(MAIN_VIEW_TYPE);
await this.app.workspace.getLeaf(true).setViewState({
type: MAIN_VIEW_TYPE,
active: true,
});
this.app.workspace.revealLeaf(
this.app.workspace.getLeavesOfType(MAIN_VIEW_TYPE)[0]
);
}
toggleView() {
const leaves = this.app.workspace.getLeavesOfType(MAIN_VIEW_TYPE);
if (leaves.length > 0) {
this.deactivateView();
} else {
this.openView();
}
}
async deactivateView() {
this.app.workspace.detachLeavesOfType(MAIN_VIEW_TYPE);
}
async loadSettings() {
const loadedData = await this.loadData();
const parsedData = customParse(loadedData) as Partial<StoredData>;
@ -290,7 +262,6 @@ export default class VisionRecallPlugin extends Plugin {
await this.saveData(mergedData);
}
async loadScreenshotMetadata(): Promise<void> {
this.metadata = this.dataManager.getAllEntries();
}
@ -317,7 +288,7 @@ export default class VisionRecallPlugin extends Plugin {
for (const child of children) {
if (child instanceof TFile && child.extension === 'json') {
try {
const metadataContent = await this.app.vault.read(child);
const metadataContent = await this.app.vault.cachedRead(child);
const metadata = JSON.parse(metadataContent);
metadataArray.push(metadata);
metadata?.extractedTags?.forEach(tag => {

View file

@ -1,6 +1,7 @@
import { OPENROUTER_HEADERS } from '@/constants';
import { tagsJsonSchema, TagsSchema } from '@/lib/tag-utils';
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.";
@ -40,18 +41,22 @@ export async function callLLMAPI(
}
try {
const response = await fetch(`${baseUrl}${apiEndpoint}`, {
const request: RequestUrlParam = {
url: `${baseUrl}${apiEndpoint}`,
method: 'POST',
headers: headers,
body: JSON.stringify(payload),
});
};
if (!response.ok) {
console.error(`LLM API Error: ${response.status} ${response.statusText}`);
const response: RequestUrlResponse = await requestUrl(request);
if (response.status !== 200) {
console.error(`LLM API Error: ${response.status}`);
return null;
}
const data = await response.json();
// const data = await response.json();
const data = response.json;
if (settings.llmProvider === 'openai' || settings.llmProvider === 'ollama') {
return data?.choices?.[0]?.message?.content || null;
@ -150,8 +155,6 @@ export async function llmSuggestTagsAndTitle(settings: VisionRecallPluginSetting
return DEFAULT_TAGS_AND_TITLE;
}
console.log('LLM Response Text:', llmResponseText);
try {
const { title, tags } = extractTitleAndTags(llmResponseText);
return {
@ -218,17 +221,20 @@ export async function fetchLLMAPIGet(
}
try {
const response = await fetch(`${baseUrl}${apiEndpoint}`, {
const request: RequestUrlParam = {
url: `${baseUrl}${apiEndpoint}`,
method: 'GET',
headers: headers,
});
};
if (!response.ok) {
console.error(`LLM API Error: ${response.status} ${response.statusText}`);
const response = await requestUrl(request);
if (response.status !== 200) {
console.error(`LLM API Error: ${response.status}`);
return null;
}
const data = await response.json();
const data = response.json;
return data;
} catch (error) {
@ -267,13 +273,13 @@ type OllamaApiResponse = {
async function fetchOllamaModels(endpointUrl: string): Promise<OllamaModel[]> {
try {
let endpoint = adjustEndpoint(endpointUrl, true)
const response = await fetch(`${endpoint}/api/tags`);
const response = await requestUrl(`${endpoint}/api/tags`);
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
if (response.status !== 200) {
throw new Error(`Failed to fetch models: ${response.status}`);
}
const data: OllamaApiResponse = await response.json();
const data: OllamaApiResponse = response.json;
return data.models;
} catch (error) {
console.error("Error fetching Ollama models:", error);
@ -301,18 +307,21 @@ type OpenAIModelsResponse = {
async function fetchOpenAIModels(endpointUrl: string, apiKey: string): Promise<OpenAIModel[]> {
try {
let endpoint = adjustEndpoint(endpointUrl, true)
const response = await fetch(`${endpoint}/v1/models`, {
const request: RequestUrlParam = {
url: `${endpoint}/v1/models`,
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json'
}
});
};
if (!response.ok) {
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText}`);
const response = await requestUrl(request);
if (response.status !== 200) {
throw new Error(`Failed to fetch models: ${response.status}`);
}
const data: OpenAIModelsResponse = await response.json();
const data: OpenAIModelsResponse = response.json;
return data.data;
} catch (error) {
console.error("Error fetching OpenAI models:", error);

View file

@ -1,45 +1,11 @@
import VisionRecallPlugin from '@/main';
import { Notice, Plugin, TFile } from 'obsidian';
// Generated by ChatGPT and not part of the rest of the functions below
export const findNotesWithTag = (plugin: VisionRecallPlugin, tag: string): string[] => {
const filesWithTag: string[] = [];
const cachedFiles = Object.keys(plugin.app.metadataCache.getCache('files') || {});
cachedFiles.forEach((filePath) => {
const metadata = plugin.app.metadataCache.getCache(filePath);
if (metadata && metadata.tags) {
const tags = metadata.tags.map(tagObj => tagObj.tag);
if (tags.includes(tag)) {
filesWithTag.push(filePath);
}
}
});
return filesWithTag;
}
export const findFirstNoteWithTag = async (plugin: Plugin, tag: string): Promise<string | null> => {
const cachedFiles = Object.keys(plugin.app.metadataCache.getCache('files') || {});
cachedFiles.forEach((filePath) => {
const metadata = plugin.app.metadataCache.getCache(filePath);
if (metadata && metadata.tags) {
const tags = metadata.tags.map(tagObj => tagObj.tag);
if (tags.includes(tag)) {
return filePath;
}
}
});
return null;
}
export const findNotesWithTagInline = async (plugin: VisionRecallPlugin, tag: string): Promise<string[]> => {
const matchingFiles: string[] = [];
for (const file of plugin.app.vault.getMarkdownFiles()) {
const content = await plugin.app.vault.read(file);
const content = await plugin.app.vault.cachedRead(file);
if (content.includes(tag)) {
matchingFiles.push(file.path);
}
@ -48,27 +14,6 @@ export const findNotesWithTagInline = async (plugin: VisionRecallPlugin, tag: st
return matchingFiles;
}
export const openNoteWithTagOld = async (plugin: VisionRecallPlugin, tag: string): Promise<void> => {
const file = await findFirstNoteWithTag(plugin, tag);
if (file) {
await plugin.app.workspace.openLinkText(file, '', false, { active: true });
} else {
plugin.logger.info(`No notes found with tag ${tag}`);
}
// const files = findNotesWithTag(plugin, tag);
// if (files.length > 0) {
// const file = plugin.app.vault.getAbstractFileByPath(files[0]);
// if (file instanceof TFile) {
// // plugin.app.workspace.getMostRecentLeaf().openFile(file); // might want to open in a new tab instead
// await plugin.app.workspace.openLinkText(file.path, '', false, { active: true });
// }
// } else {
// console.log(`No notes found with tag ${tag}`);
// }
}
// --- Helper Functions for Note Linking by Full Unique Tag (Using metadataCache) ---
export async function getNoteByUniqueTagOrCreateTag(plugin: VisionRecallPlugin, noteName: string, parentTagPrefix: string): Promise<TFile | null> {
@ -119,7 +64,7 @@ export async function findNoteByUniqueTag(plugin: VisionRecallPlugin, fullUnique
export async function ensureUniqueTagExists(plugin: VisionRecallPlugin, file: TFile, parentTagPrefix: string): Promise<string | null> {
const { vault } = plugin.app;
let fileContent = await vault.read(file);
let fileContent = await vault.cachedRead(file);
const existingTag = await findUniqueTagInContent(fileContent, parentTagPrefix);
if (existingTag) {
@ -131,7 +76,11 @@ export async function ensureUniqueTagExists(plugin: VisionRecallPlugin, file: TF
// Append the new tag to the end of the file content
const updatedContent = fileContent.trimEnd() + `\n\n${newUniqueTag}`;
await vault.modify(file, updatedContent);
vault.process(file, (data: string) => {
return updatedContent;
});
return newUniqueTag;
}
}

View file

@ -1,4 +1,4 @@
import { App, Notice, TFile, TFolder, normalizePath } from 'obsidian';
import { App, FileManager, Notice, TFile, TFolder, 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, llmSuggestTagsAndTitle } from '@/services/llm-service';
@ -196,8 +196,9 @@ export class ScreenshotProcessor {
newScreenshotPath: string;
}> {
const screenshotStorageFolder = normalizePath(await this.plugin.getFolderFromSettingsKey('screenshotStorageFolderPath'));
if (!await this.app.vault.adapter.exists(screenshotStorageFolder)) {
await this.app.vault.adapter.mkdir(screenshotStorageFolder);
const screenshotStorageFolderExists = this.app.vault.getFolderByPath(screenshotStorageFolder);
if (!screenshotStorageFolderExists) {
await this.app.vault.createFolder(screenshotStorageFolder);
}
// const timestamp = Date.now();
@ -611,17 +612,26 @@ export class ScreenshotProcessor {
this.plugin.logger.debug(`Deleting metadata for ${params.identity}`);
if (currentMetadata?.metadataPath) {
await this.app.vault.adapter.trashLocal(currentMetadata.metadataPath);
const metadataFile = this.app.vault.getAbstractFileByPath(currentMetadata.metadataPath);
if (metadataFile instanceof TFile) {
await this.app.fileManager.trashFile(metadataFile);
}
} else {
const screenshotFilename = currentMetadata.screenshotFilename.split('.').slice(0, -1).join('.');
const metadataFilename = `${screenshotFilename}.json`;
const metadataPath = normalizePath(`${screenshotStorageFolder}/${metadataFilename}`);
await this.app.vault.adapter.trashLocal(metadataPath);
const metadataFile = this.app.vault.getAbstractFileByPath(metadataPath);
if (metadataFile instanceof TFile) {
await this.app.fileManager.trashFile(metadataFile);
}
}
if (currentMetadata?.screenshotStoragePath) {
await this.app.vault.adapter.trashLocal(currentMetadata.screenshotStoragePath);
const screenshotFile = this.app.vault.getAbstractFileByPath(currentMetadata.screenshotStoragePath);
if (screenshotFile instanceof TFile) {
await this.app.fileManager.trashFile(screenshotFile);
}
}
}
}
@ -654,30 +664,34 @@ export class ScreenshotProcessor {
if (updatedMetadata.notePath) {
const noteFile = this.app.vault.getAbstractFileByPath(updatedMetadata.notePath);
if (noteFile instanceof TFile) {
const currentNoteContent = await this.app.vault.read(noteFile);
this.app.vault.process(noteFile, (data: string) => {
const currentNoteContent = data;
// Replace the tags section in the note
const formattedTags = tagsToCommaString(formatTags(updatedMetadata.extractedTags));
const updatedNoteContent = currentNoteContent.replace(
/\*Tags:\*.*/,
`*Tags:* ${formattedTags}`
);
// Replace the tags section in the note
const formattedTags = tagsToCommaString(formatTags(updatedMetadata.extractedTags));
const updatedNoteContent = currentNoteContent.replace(
/\*Tags:\*.*/,
`*Tags:* ${formattedTags}`
);
// Replace the notes section (everything between the title and the first ---)
const titleAndNotes = updatedNoteContent.split('---')[0];
const restOfContent = updatedNoteContent.split('---').slice(1).join('---');
const updatedTitleAndNotes = titleAndNotes.replace(
/# Notes from screenshot:.*?\n\n(.*?)\n\n/s,
`# Notes from screenshot: ${updatedMetadata.noteTitle.replace('.md', '')}\n\n${updatedMetadata.generatedNotes}\n\n`
);
const finalNoteContent = `${updatedTitleAndNotes}---${restOfContent}`;
// Replace the notes section (everything between the title and the first ---)
const titleAndNotes = updatedNoteContent.split('---')[0];
const restOfContent = updatedNoteContent.split('---').slice(1).join('---');
const updatedTitleAndNotes = titleAndNotes.replace(
/# Notes from screenshot:.*?\n\n(.*?)\n\n/s,
`# Notes from screenshot: ${updatedMetadata.noteTitle.replace('.md', '')}\n\n${updatedMetadata.generatedNotes}\n\n`
);
const finalNoteContent = `${updatedTitleAndNotes}---${restOfContent}`;
await this.app.vault.modify(noteFile, finalNoteContent);
return finalNoteContent;
});
}
}
// Save the updated metadata to the JSON file
await this.app.vault.modify(file, JSON.stringify(updatedMetadata, null, 2));
this.app.vault.process(file, (data: string) => {
return JSON.stringify(updatedMetadata, null, 2);
});
new Notice('Screenshot metadata updated successfully');
} catch (error) {
@ -720,8 +734,9 @@ export class ScreenshotProcessor {
const extension = type.split('/')[1];
const fileName = `clipboard-image-${timestamp}.${extension}`;
if (!await this.app.vault.adapter.exists(folderPath)) {
await this.app.vault.adapter.mkdir(folderPath);
const screenshotStorageFolder = this.app.vault.getFolderByPath(folderPath);
if (!screenshotStorageFolder) {
await this.app.vault.createFolder(folderPath);
}
const filePath = normalizePath(`${folderPath}/${fileName}`);
@ -786,7 +801,10 @@ export class ScreenshotProcessor {
}
async deleteFile(filePath: string) {
await this.app.vault.adapter.trashLocal(filePath);
const fileToDelete = this.app.vault.getAbstractFileByPath(filePath);
if (fileToDelete instanceof TFile) {
await this.app.fileManager.trashFile(fileToDelete);
}
}
async processIntakeFolderAuto() {

View file

@ -1,4 +1,5 @@
import VisionRecallPlugin from '@/main';
import { normalizePath } from 'obsidian';
export async function initializeFolders(plugin: VisionRecallPlugin) {
try {
@ -30,10 +31,16 @@ export async function initializeFolders(plugin: VisionRecallPlugin) {
}
}
/**
* Sanitizes a filename by replacing problematic characters with an empty string
* This is used in addition to normalizePath() (which is used 20+ times in this project)
* @param input - The filename to sanitize
* @returns The sanitized filename
*/
export function sanitizeFilename(input: string): string {
// Define a regex pattern to match problematic filename characters
const forbiddenChars = /[<>:"\/\\|?*\x00-\x1F]/g
// Replace them with an empty string
return input.replace(forbiddenChars, '')
return normalizePath(input.replace(forbiddenChars, ''))
}

View file

@ -28,20 +28,23 @@ export default class VisionRecallSettingTab extends PluginSettingTab {
.onChange(async (value: 'openai' | 'ollama') => {
this.plugin.settings.llmProvider = value;
await this.plugin.saveSettings();
this.display();
})
);
new Setting(containerEl)
.setName('API key')
.setDesc('OpenAI API key (not needed for Ollama).')
.addText(text => text
.setPlaceholder('Enter your API key')
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.apiKey = value;
await this.plugin.saveSettings();
})
);
if (this.plugin.settings.llmProvider != 'ollama') {
new Setting(containerEl)
.setName('API key')
.setDesc('OpenAI API key (not needed for Ollama).')
.addText(text => text
.setPlaceholder('Enter your API key')
.setValue(this.plugin.settings.apiKey)
.onChange(async (value) => {
this.plugin.settings.apiKey = value;
await this.plugin.saveSettings();
})
);
}
new Setting(containerEl)
.setName('API base URL')
@ -89,20 +92,23 @@ export default class VisionRecallSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.useParentFolder = value;
await this.plugin.saveSettings();
this.display();
})
);
new Setting(containerEl)
.setName('Parent folder path')
.setDesc('Path to the parent folder. Optional if not using parent folder organization.')
.addText(text => text
.setPlaceholder('e.g., VisionRecall')
.setValue(this.plugin.settings.parentFolderPath)
.onChange(async (value) => {
this.plugin.settings.parentFolderPath = value;
await this.plugin.saveSettings();
})
);
if (this.plugin.settings.useParentFolder) {
new Setting(containerEl)
.setName('Parent folder path')
.setDesc('Path to the parent folder. Optional if not using parent folder organization.')
.addText(text => text
.setPlaceholder('e.g., VisionRecall')
.setValue(this.plugin.settings.parentFolderPath)
.onChange(async (value) => {
this.plugin.settings.parentFolderPath = value;
await this.plugin.saveSettings();
})
);
}
new Setting(containerEl)
.setName('Add prefix to folder names')
@ -112,20 +118,23 @@ export default class VisionRecallSettingTab extends PluginSettingTab {
.onChange(async (value) => {
this.plugin.settings.addPrefixToFolderNames = value;
await this.plugin.saveSettings();
this.display();
})
);
new Setting(containerEl)
.setName('Prefix to add to folder names')
.setDesc('Prefix for folder names. Optional.')
.addText(text => text
.setPlaceholder('e.g., VisionRecall-')
.setValue(this.plugin.settings.prefixToAddToFolderNames)
.onChange(async (value) => {
this.plugin.settings.prefixToAddToFolderNames = value;
await this.plugin.saveSettings();
})
);
if (this.plugin.settings.addPrefixToFolderNames) {
new Setting(containerEl)
.setName('Prefix to add to folder names')
.setDesc('Prefix for folder names. Optional.')
.addText(text => text
.setPlaceholder('e.g., VisionRecall-')
.setValue(this.plugin.settings.prefixToAddToFolderNames)
.onChange(async (value) => {
this.plugin.settings.prefixToAddToFolderNames = value;
await this.plugin.saveSettings();
})
);
}
new Setting(containerEl)
.setName('Screenshot storage folder')

View file

@ -1,113 +0,0 @@
.config-modal-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.config-field {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
/* padding: 0.5rem 0; */
padding-bottom: 1rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.config-field label {
font-weight: 500;
/* margin-bottom: 0.25rem; */
}
.config-field-checkbox-label {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.config-field input[type='text'],
.config-field input[type='number'],
.config-field select {
padding: 0.5rem;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
}
.config-multiselect {
height: 100px;
padding: 0.5rem;
}
.config-modal-controls {
display: flex;
gap: 1rem;
justify-content: flex-end;
/* padding: 1rem 0; */
margin-top: 1rem;
}
/* Debug Operations Modal Styles */
.debug-operations-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
}
.debug-section {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background-color: var(--background-secondary);
border-radius: 8px;
}
.debug-section h3 {
margin: 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.debug-description {
color: var(--text-muted);
font-size: 0.9em;
line-height: 1.4;
}
.debug-results {
padding: 1rem;
background-color: var(--background-secondary);
border-radius: 8px;
}
.debug-results h4 {
margin: 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.results-details {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.result-item {
display: flex;
justify-content: space-between;
padding: 0.5rem;
background-color: var(--background-primary);
border-radius: 4px;
}
.result-item span:first-child {
color: var(--text-muted);
}

View file

@ -0,0 +1,115 @@
.vr {
.config-modal-form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.config-field {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
/* padding: 0.5rem 0; */
padding-bottom: 1rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.config-field label {
font-weight: 500;
/* margin-bottom: 0.25rem; */
}
.config-field-checkbox-label {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.config-field input[type='text'],
.config-field input[type='number'],
.config-field select {
padding: 0.5rem;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
}
.config-multiselect {
height: 100px;
padding: 0.5rem;
}
.config-modal-controls {
display: flex;
gap: 1rem;
justify-content: flex-end;
/* padding: 1rem 0; */
margin-top: 1rem;
}
/* Debug Operations Modal Styles */
.debug-operations-content {
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1rem;
}
.debug-section {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
background-color: var(--background-secondary);
border-radius: 8px;
}
.debug-section h3 {
margin: 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.debug-description {
color: var(--text-muted);
font-size: 0.9em;
line-height: 1.4;
}
.debug-results {
padding: 1rem;
background-color: var(--background-secondary);
border-radius: 8px;
}
.debug-results h4 {
margin: 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--background-modifier-border);
}
.results-details {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.result-item {
display: flex;
justify-content: space-between;
padding: 0.5rem;
background-color: var(--background-primary);
border-radius: 4px;
}
.result-item span:first-child {
color: var(--text-muted);
}
}

View file

@ -0,0 +1,43 @@
.vr-delete-confirmation-modal {
padding: 20px;
}
.vr-delete-confirmation-modal h3 {
margin-bottom: 20px;
}
.vr-delete-confirmation-modal .form-group {
margin-bottom: 15px;
}
.vr-delete-confirmation-modal label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.vr-delete-confirmation-modal input,
.vr-delete-confirmation-modal textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
}
.vr-delete-confirmation-modal .button-group {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.vr-delete-confirmation-modal button {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.vr-delete-confirmation-modal button[type='submit'] {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}

View file

@ -1,43 +1,43 @@
.edit-metadata-modal {
.vr-edit-metadata-modal {
padding: 20px;
}
.edit-metadata-modal h3 {
.vr-edit-metadata-modal h3 {
margin-bottom: 20px;
}
.edit-metadata-modal .form-group {
.vr-edit-metadata-modal .form-group {
margin-bottom: 15px;
}
.edit-metadata-modal label {
.vr-edit-metadata-modal label {
display: block;
margin-bottom: 5px;
font-weight: 500;
}
.edit-metadata-modal input,
.edit-metadata-modal textarea {
.vr-edit-metadata-modal input,
.vr-edit-metadata-modal textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
}
.edit-metadata-modal .button-group {
.vr-edit-metadata-modal .button-group {
display: flex;
gap: 10px;
justify-content: flex-end;
margin-top: 20px;
}
.edit-metadata-modal button {
.vr-edit-metadata-modal button {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
}
.edit-metadata-modal button[type='submit'] {
.vr-edit-metadata-modal button[type='submit'] {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
}

View file

@ -1,91 +0,0 @@
.processing-queue-modal {
padding: 20px;
max-height: 500px;
display: flex;
flex-direction: column;
}
.queue-controls {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.queue-control-button {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
background-color: var(--background-modifier-hover);
color: var(--text-normal);
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.queue-control-button:hover {
background-color: var(--background-modifier-active);
}
.queue-status {
font-size: 14px;
margin-bottom: 16px;
}
.queue-stats {
display: flex;
gap: 15px;
margin-bottom: 20px;
padding: 10px;
background: var(--background-secondary);
border-radius: 5px;
font-size: 14px;
}
.queue-items {
overflow-y: auto;
flex-grow: 1;
}
.queue-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid var(--background-modifier-border);
gap: 10px;
}
.status-processing {
background: var(--background-modifier-hover);
}
.status-completed {
color: var(--text-success);
}
.status-failed {
color: var(--text-error);
}
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--text-muted);
border-top-color: var(--text-normal);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.item-error {
color: var(--text-error);
font-size: 0.9em;
margin-left: auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,93 @@
.vr {
.processing-queue-modal {
padding: 20px;
max-height: 500px;
display: flex;
flex-direction: column;
}
.queue-controls {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.queue-control-button {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: 4px;
background-color: var(--background-modifier-hover);
color: var(--text-normal);
font-size: 14px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.queue-control-button:hover {
background-color: var(--background-modifier-active);
}
.queue-status {
font-size: 14px;
margin-bottom: 16px;
}
.queue-stats {
display: flex;
gap: 15px;
margin-bottom: 20px;
padding: 10px;
background: var(--background-secondary);
border-radius: 5px;
font-size: 14px;
}
.queue-items {
overflow-y: auto;
flex-grow: 1;
}
.queue-item {
display: flex;
align-items: center;
padding: 10px;
border-bottom: 1px solid var(--background-modifier-border);
gap: 10px;
}
.status-processing {
background: var(--background-modifier-hover);
}
.status-completed {
color: var(--text-success);
}
.status-failed {
color: var(--text-error);
}
.loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid var(--text-muted);
border-top-color: var(--text-normal);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.item-error {
color: var(--text-error);
font-size: 0.9em;
margin-left: auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
}

View file

@ -1,79 +0,0 @@
.tag-combobox {
position: relative; /* For positioning suggestions */
background-color: var(--background-secondary);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
padding: 5px;
display: flex; /* For horizontal layout */
flex-wrap: wrap; /* Allow tags to wrap */
align-items: center; /* Vertically align items */
min-height: 30px; /* Ensure a reasonable minimum height */
}
.tag-input {
border: none;
outline: none;
flex-grow: 1; /* Take up remaining space */
padding: 5px;
min-width: 100px; /* Prevent collapsing when empty */
}
.selected-tags {
display: flex;
flex-wrap: wrap;
margin-right: 5px; /* Spacing from input */
}
.tag-pill {
/* background-color: var(--interactive-accent); */
background-color: var(--background-secondary-alt);
color: var(--text-on-accent);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
margin: 2px;
display: inline-flex; /* For button alignment */
align-items: center;
}
.remove-tag-button {
background: none;
border: none;
color: #666;
cursor: pointer;
margin-left: 5px;
font-size: 0.8em;
padding: 0; /* Reset padding */
line-height: 1; /* Reset line-height */
width: 16px;
height: 16px;
}
.suggestions-list {
position: absolute; /* Position below the input */
top: 100%; /* Below the tag-combobox */
left: 0;
right: 0;
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
/* border-top: none; */
/* border-radius: 0 0 4px 4px; */
border-radius: 4px;
list-style: none;
padding: 0;
margin: 0;
z-index: 10; /* Ensure it's above other content */
max-height: 200px; /* Limit height for scrollability */
overflow-y: auto;
overflow-x: clip;
box-sizing: border-box;
}
.suggestions-list li {
padding: 8px 10px;
cursor: pointer;
}
.suggestions-list li:hover {
background-color: var(--background-modifier-border);
}

View file

@ -0,0 +1,81 @@
.vr {
.tag-combobox {
position: relative; /* For positioning suggestions */
background-color: var(--background-secondary);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
padding: 5px;
display: flex; /* For horizontal layout */
flex-wrap: wrap; /* Allow tags to wrap */
align-items: center; /* Vertically align items */
min-height: 30px; /* Ensure a reasonable minimum height */
}
.tag-input {
border: none;
outline: none;
flex-grow: 1; /* Take up remaining space */
padding: 5px;
min-width: 100px; /* Prevent collapsing when empty */
}
.selected-tags {
display: flex;
flex-wrap: wrap;
margin-right: 5px; /* Spacing from input */
}
.tag-pill {
/* background-color: var(--interactive-accent); */
background-color: var(--background-secondary-alt);
color: var(--text-on-accent);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
margin: 2px;
display: inline-flex; /* For button alignment */
align-items: center;
}
.remove-tag-button {
background: none;
border: none;
color: #666;
cursor: pointer;
margin-left: 5px;
font-size: 0.8em;
padding: 0; /* Reset padding */
line-height: 1; /* Reset line-height */
width: 16px;
height: 16px;
}
.suggestions-list {
position: absolute; /* Position below the input */
top: 100%; /* Below the tag-combobox */
left: 0;
right: 0;
background-color: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
/* border-top: none; */
/* border-radius: 0 0 4px 4px; */
border-radius: 4px;
list-style: none;
padding: 0;
margin: 0;
z-index: 10; /* Ensure it's above other content */
max-height: 200px; /* Limit height for scrollability */
overflow-y: auto;
overflow-x: clip;
box-sizing: border-box;
}
.suggestions-list li {
padding: 8px 10px;
cursor: pointer;
}
.suggestions-list li:hover {
background-color: var(--background-modifier-border);
}
}

View file

@ -1,94 +0,0 @@
.view-metadata-modal {
/* padding: 20px; */
/* max-width: 600px; */
margin: 0 auto;
}
.view-metadata-modal h3 {
margin-bottom: 20px;
font-size: 1.5em;
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 10px;
}
.view-metadata-modal h4 {
/* margin: 15px 0 10px; */
margin: 0 0 0 0;
padding-bottom: 10px;
font-size: 1.1em;
color: var(--text-muted);
}
.metadata-section {
margin-bottom: 20px;
padding: 15px;
background-color: var(--background-secondary);
border-radius: 8px;
}
.metadata-group {
margin-bottom: 12px;
}
.metadata-group:last-child {
margin-bottom: 0;
}
.metadata-group label {
display: block;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-muted);
font-size: 0.9em;
}
.metadata-value {
padding: 8px;
background-color: var(--background-primary);
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
}
.metadata-value.scrollable {
max-height: 150px;
overflow-y: auto;
white-space: pre-wrap;
}
.metadata-value.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
}
.tag {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
}
.no-tags {
color: var(--text-muted);
font-style: italic;
}
.button-group {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.button-group button {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
background-color: var(--interactive-normal);
color: var(--text-normal);
}
.button-group button:hover {
background-color: var(--interactive-hover);
}

View file

@ -0,0 +1,96 @@
.vr {
.view-metadata-modal {
/* padding: 20px; */
/* max-width: 600px; */
margin: 0 auto;
}
.view-metadata-modal h3 {
margin-bottom: 20px;
font-size: 1.5em;
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: 10px;
}
.view-metadata-modal h4 {
/* margin: 15px 0 10px; */
margin: 0 0 0 0;
padding-bottom: 10px;
font-size: 1.1em;
color: var(--text-muted);
}
.metadata-section {
margin-bottom: 20px;
padding: 15px;
background-color: var(--background-secondary);
border-radius: 8px;
}
.metadata-group {
margin-bottom: 12px;
}
.metadata-group:last-child {
margin-bottom: 0;
}
.metadata-group label {
display: block;
font-weight: 600;
margin-bottom: 4px;
color: var(--text-muted);
font-size: 0.9em;
}
.metadata-value {
padding: 8px;
background-color: var(--background-primary);
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
}
.metadata-value.scrollable {
max-height: 150px;
overflow-y: auto;
white-space: pre-wrap;
}
.metadata-value.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
}
.tag {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
padding: 4px 8px;
border-radius: 4px;
font-size: 0.9em;
}
.no-tags {
color: var(--text-muted);
font-style: italic;
}
.button-group {
display: flex;
justify-content: flex-end;
margin-top: 20px;
}
.button-group button {
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
background-color: var(--interactive-normal);
color: var(--text-normal);
}
.button-group button:hover {
background-color: var(--interactive-hover);
}
}

View file

@ -1,42 +1,45 @@
@import './components/processing-display.css';
@import './components/edit-metadata-modal.css';
@import './components/loaders.css';
@import './components/view-metadata-modal.css';
@import './components/config-modal.css';
@import './components/processing-queue.css';
@import './components/tag-combobox.css';
@use './components/processing-display.scss';
@use './components/edit-metadata-modal.scss';
@use './components/loaders.scss';
@use './components/view-metadata-modal.scss';
@use './components/config-modal.scss';
@use './components/processing-queue.scss';
@use './components/tag-combobox.scss';
@use './components/delete-confirmation-modal.scss';
@tailwind base;
@layer base {
:root {
.vision-recall-styling {
--dialog-width: calc(min(auto, 100%));
--dialog-height: calc(min(auto, 100%));
}
.settings-display {
padding: 1rem;
width: 100%;
max-width: 500px;
}
.vision-recall-styling {
.settings-display {
padding: 1rem;
width: 100%;
max-width: 500px;
}
.setting-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--background-modifier-border);
}
.setting-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid var(--background-modifier-border);
}
.setting-item:last-child {
border-bottom: none;
}
.setting-item:last-child {
border-bottom: none;
}
.setting-label {
font-weight: bold;
color: var(--text-muted);
}
.setting-label {
font-weight: bold;
color: var(--text-muted);
}
.setting-value {
color: var(--text-normal);
.setting-value {
color: var(--text-normal);
}
}
.button-group {
@ -96,7 +99,7 @@
color: var(--interactive-accent);
}
.loader {
.vr-loader {
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #000;
border-radius: 50%;
@ -125,9 +128,9 @@
margin-top: 40px;
}
.settings-header {
/* .settings-header {
margin-top: 40px;
}
} */
.vision-recall-view {
padding-right: 20px;

View file

@ -7,6 +7,7 @@ import { useDataContext } from '@/data/DataContext';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { STORAGE_KEYS, DEFAULT_ITEMS_PER_PAGE } from '@/constants';
import TagCombobox from '@/components/ui/tag-combobox';
import { App } from 'obsidian';
export interface BaseViewProps {
initialMetadata: any[];
@ -54,7 +55,7 @@ export const useBaseView = ({ initialMetadata, viewMode, onViewModeChange }: Bas
const [filteredMetadata, setFilteredMetadata] = useState([]);
const [foldersInitialized, setFoldersInitialized] = useState<boolean>(() => {
const saved = localStorage.getItem('vision-recall-initialize-folders');
const saved = app.loadLocalStorage('vision-recall-initialize-folders');
return saved ? saved === 'true' : false;
});
@ -81,7 +82,7 @@ export const useBaseView = ({ initialMetadata, viewMode, onViewModeChange }: Bas
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEYS.ITEMS_PER_PAGE);
const saved = app.loadLocalStorage(STORAGE_KEYS.ITEMS_PER_PAGE);
const parsed = saved ? Number(saved) : DEFAULT_ITEMS_PER_PAGE;
return isNaN(parsed) ? DEFAULT_ITEMS_PER_PAGE : parsed;
} catch (e) {
@ -92,7 +93,7 @@ export const useBaseView = ({ initialMetadata, viewMode, onViewModeChange }: Bas
const [sortCriteria, setSortCriteria] = useState<SortCriteria>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEYS.SORT_CRITERIA);
const saved = app.loadLocalStorage(STORAGE_KEYS.SORT_CRITERIA);
const parsed = saved ? JSON.parse(saved) : {
column: 'timestamp',
direction: 'desc'
@ -115,7 +116,7 @@ export const useBaseView = ({ initialMetadata, viewMode, onViewModeChange }: Bas
direction: sortCriteria.column === columnName && sortCriteria.direction === 'asc' ? 'desc' : 'asc', // Toggle direction if same column is clicked
} as SortCriteria;
setSortCriteria(newSortCriteria);
localStorage.setItem(STORAGE_KEYS.SORT_CRITERIA, JSON.stringify(newSortCriteria));
app.saveLocalStorage(STORAGE_KEYS.SORT_CRITERIA, JSON.stringify(newSortCriteria));
} catch (e) {
console.warn('Failed to save sort criteria:', e);
}
@ -256,7 +257,7 @@ export const useBaseView = ({ initialMetadata, viewMode, onViewModeChange }: Bas
try {
setItemsPerPage(newValue);
setCurrentPage(1);
localStorage.setItem(STORAGE_KEYS.ITEMS_PER_PAGE, newValue.toString());
app.saveLocalStorage(STORAGE_KEYS.ITEMS_PER_PAGE, newValue.toString());
} catch (e) {
console.warn('Failed to save items per page preference:', e);
}

View file

@ -8,6 +8,7 @@ import { MainViewHeader } from '@/components/MainViewHeader';
import { ViewMetadataModal } from '../components/modals/ViewMetadataModal';
import { openNoteWithTag } from '@/services/note-link-service';
import { BaseViewProps, useBaseView } from './BaseView';
import { DeleteConfirmationModal } from '@/components/modals/DeleteConfirmationModal';
const GalleryView = (props: BaseViewProps) => {
const {
@ -122,15 +123,20 @@ const GalleryView = (props: BaseViewProps) => {
<span
aria-label="Delete screenshot"
className="w-5 h-5 cursor-pointer"
onClick={async () => {
const shouldDelete = window.confirm('Are you sure you want to delete this screenshot and its metadata?');
if (shouldDelete) {
onClick={() => {
const modal = new DeleteConfirmationModal(app, plugin, 'Are you sure you want to delete this screenshot and its metadata?', async () => {
await plugin.screenshotProcessor.deleteScreenshotMetadata({
identity: item.id,
identityType: 'id'
});
await refreshMetadata();
}
// modal.close();
},
() => {
modal.close();
}
);
modal.open();
}}
>
<Trash2 />
@ -156,7 +162,7 @@ const GalleryView = (props: BaseViewProps) => {
{DateTime.fromISO(item.timestamp).toFormat('yyyy/MM/dd HH:mm')}
</div>
<div className="gallery-tags">
{item.extractedTags ? item.extractedTags.join(', ') : 'No Tags'}
{item.extractedTags ? item.extractedTags.join(', ') : 'No tags'}
</div>
</div>
</div>

View file

@ -8,6 +8,7 @@ import { MainViewHeader } from '@/components/MainViewHeader';
import { ViewMetadataModal } from '../components/modals/ViewMetadataModal';
import { openNoteWithTag } from '@/services/note-link-service';
import { BaseViewProps, useBaseView } from './BaseView';
import { DeleteConfirmationModal } from '@/components/modals/DeleteConfirmationModal';
const ListView = (props: BaseViewProps) => {
const {
@ -102,7 +103,7 @@ const ListView = (props: BaseViewProps) => {
{item.title}
</span>
<span>{DateTime.fromISO(item.timestamp).toFormat('yyyy/MM/dd HH:mm')}</span>
<span>{item.extractedTags ? item.extractedTags.join(', ') : 'No Tags'}</span>
<span>{item.extractedTags ? item.extractedTags.join(', ') : 'No tags'}</span>
<span className='flex flex-row items-center gap-2'>
<button
aria-label='View metadata'
@ -137,14 +138,18 @@ const ListView = (props: BaseViewProps) => {
aria-label='Delete screenshot'
className='cursor-pointer'
onClick={async () => {
const shouldDelete = window.confirm('Are you sure you want to delete this screenshot and its metadata?');
if (shouldDelete) {
const modal = new DeleteConfirmationModal(app, plugin, 'Are you sure you want to delete this screenshot and its metadata?', async () => {
await plugin.screenshotProcessor.deleteScreenshotMetadata({
identity: item.timestamp,
identityType: 'timestamp'
});
await refreshMetadata();
}
},
() => {
modal.close();
}
);
modal.open();
}}>
<Trash2 className='w-4 h-4' />
</button>

View file

@ -46,7 +46,7 @@ export default class MainView extends ItemView {
const metadata = this.plugin.dataManager.getAllEntries();
// Create wrapper div for React
const reactContainer = container.createDiv();
const reactContainer = container.createDiv({ cls: 'vision-recall-styling' });
// Create React root and render app
this.root = createRoot(reactContainer);
@ -76,7 +76,7 @@ const ViewContainer = ({ initialMetadata }) => {
const [viewMode, setViewMode] = React.useState<'list' | 'gallery'>(() => {
try {
const saved = localStorage.getItem('vision-recall-view-mode');
const saved = plugin.app.loadLocalStorage('vision-recall-view-mode');
return saved === 'gallery' ? 'gallery' : 'list';
} catch {
// return 'list';
@ -87,7 +87,7 @@ const ViewContainer = ({ initialMetadata }) => {
const handleViewModeChange = (mode: 'list' | 'gallery') => {
setViewMode(mode);
try {
localStorage.setItem('vision-recall-view-mode', mode);
plugin.app.saveLocalStorage('vision-recall-view-mode', mode);
} catch (e) {
plugin.logger.error('Failed to save view mode preference:', e);
}

View file

@ -17,7 +17,7 @@
"jsx": "react",
"paths": {
"@/*": ["./src/*"]
},
}
},
"include": ["**/*.ts", "**/*.tsx", "types/*.d.ts"],
"exclude": ["node_modules"]

2
types/md.d.ts vendored
View file

@ -1,4 +1,4 @@
declare module '*.md' {
const content: string;
export default content;
}
}

View file

@ -1,6 +1,7 @@
{
"1.0.0": "0.15.0",
"1.0.1": "0.15.0",
"1.0.2": "0.15.0",
"1.0.3": "0.15.0"
}
"1.0.0": "0.15.0",
"1.0.1": "0.15.0",
"1.0.2": "0.15.0",
"1.0.3": "0.15.0",
"1.0.4": "1.8.3"
}

View file

@ -11,7 +11,15 @@ export default defineConfig(async ({ mode }) => {
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
"@": path.resolve(__dirname, "./src")
},
},
css: {
preprocessorOptions: {
scss: {
// You can add global Sass variables here if needed
// additionalData: `@import "@/styles/variables.scss";`,
},
},
},
build: {