diff --git a/common/promise.ts b/common/promise.ts
index 383ce083..b6dea6d3 100644
--- a/common/promise.ts
+++ b/common/promise.ts
@@ -56,6 +56,7 @@ export function promiseAllLimit(
if (cancel?.()) {
rejected = true;
console.log('CANCELLING!');
+ reject(new Error('Promise chain cancelled'));
return;
}
diff --git a/resources/style/controls/input.scss b/resources/style/controls/input.scss
index b9275dc2..89e962b4 100644
--- a/resources/style/controls/input.scss
+++ b/resources/style/controls/input.scss
@@ -26,6 +26,7 @@
}
}
+.split-input-wrapper,
.input {
height: 28px;
background: var(--input-color);
@@ -101,3 +102,27 @@ textarea.input {
}
}
}
+
+.split-input-wrapper {
+ display: flex;
+ flex-grow: 1;
+ flex-wrap: wrap;
+ overflow-x: hidden;
+ align-items: center;
+ user-select: text;
+ padding-left: 0.25rem;
+
+ > * {
+ user-select: text;
+ padding: 0;
+ margin: 0;
+ line-height: 1.75em;
+ }
+
+ input {
+ flex-grow: 1;
+ border: none;
+ background: transparent;
+ box-sizing: content-box;
+ }
+}
diff --git a/resources/style/controls/notifications.scss b/resources/style/controls/notifications.scss
index 51aec407..e37b0b22 100644
--- a/resources/style/controls/notifications.scss
+++ b/resources/style/controls/notifications.scss
@@ -55,10 +55,12 @@
min-height: 1rem;
max-height: 5rem;
margin: 0.25rem;
+ word-break: break-word;
}
button {
color: white;
+ min-width: auto;
&:hover {
background: rgba(255, 255, 255, 0.15);
diff --git a/src/frontend/containers/ContentView/menu-items.tsx b/src/frontend/containers/ContentView/menu-items.tsx
index 5801aad1..4e069fc8 100644
--- a/src/frontend/containers/ContentView/menu-items.tsx
+++ b/src/frontend/containers/ContentView/menu-items.tsx
@@ -97,6 +97,12 @@ export const FileViewerMenuItems = ({ file }: { file: ClientFile }) => {
text="Open Tag Selector"
icon={IconSet.TAG}
/>
+
IPageData[] = () => [
>
),
},
+ {
+ title: 'Automatic Tagging',
+ content: (
+ <>
+
+ You can set an endpoint to a locally hosted AI tagging service or any custom tagging
+ implementation, allowing the app to send requests and automatically tag files with the
+ service response. You can also configure the number of concurrent requests made to the
+ service simultaneously. For more information, see the "Background Processes" section
+ in the settings window.
+
+
+ To automatically tag selected files, use the
+ {' "Tagging... > Auto Tag Selected Using Tagging Service" '}
+ option in the file context menu.
+
+ >
+ ),
+ },
{
title: 'Tag Import/Export',
content: (
@@ -326,6 +345,10 @@ const PAGE_DATA: () => IPageData[] = () => [
Note that only the images shown in the gallery are affected by these operations!
+
+ You can also import/export tags from selected files through the "Tagging" options in
+ the file context menu.
+
>
),
},
diff --git a/src/frontend/containers/Settings/BackgroundProcesses.tsx b/src/frontend/containers/Settings/BackgroundProcesses.tsx
index 7525cf4f..4954289a 100644
--- a/src/frontend/containers/Settings/BackgroundProcesses.tsx
+++ b/src/frontend/containers/Settings/BackgroundProcesses.tsx
@@ -7,6 +7,8 @@ import { IconSet, Toggle } from 'widgets';
import { Callout } from 'widgets/notifications';
import { useStore } from '../../contexts/StoreContext';
import FileInput from 'src/frontend/components/FileInput';
+import { useGalleryInputKeydownHandler } from 'src/frontend/hooks/useHandleInputKeydown';
+import UiStore from 'src/frontend/stores/UiStore';
export const BackgroundProcesses = observer(() => {
const { uiStore, locationStore } = useStore();
@@ -36,9 +38,7 @@ export const BackgroundProcesses = observer(() => {
return (
<>
-
- Run in background
-
+
Browser Extension
You need to install the browser extension before either in the{' '}
@@ -63,6 +63,11 @@ export const BackgroundProcesses = observer(() => {
>
Run browser extension
+
+
+
+ Run in background
+
{
Download Directory
{uiStore.importDirectory || 'Not set'}
+
+
>
);
});
+
+const TaggingServiceConfig = observer(() => {
+ const { taggingServiceURL, setTaggingServiceURL } = useStore().uiStore;
+ const prehost = 'http://localhost';
+
+ const posthost = taggingServiceURL.startsWith(prehost)
+ ? taggingServiceURL.slice(prehost.length)
+ : '';
+
+ const handleKeyDown = useGalleryInputKeydownHandler();
+
+ const handleChange = (e: React.ChangeEvent) => {
+ //use URL for validations
+ let newPosthost = e.target.value.replace(prehost, '');
+ const url = new URL(newPosthost, prehost);
+ //Remove any hostname if present when pasting the full URL.
+ newPosthost = (url.pathname + url.search + url.hash).replace('/', '');
+ if (newPosthost && !newPosthost.startsWith(':') && !newPosthost.startsWith('/')) {
+ newPosthost = '/' + newPosthost;
+ }
+ setTaggingServiceURL(prehost + newPosthost);
+ };
+
+ // Custom and minimalistic implementation inspired/based on cmeka's implementation: https://github.com/cmeka/OneFolder/commit/b0d7e12
+ return (
+ <>
+ Local Tagging Service API URL
+
+ A tagging service such as{' '}
+
+ media-tag-service
+ {' '}
+ or any custom tagging endpoint must be running.
+
+
+
+ {'The endpoint must accept a JSON request with the format:'}
+
{'{ "file": "" }'}
+ {'and respond with a JSON in the format:'}
+
+ {'{'}
+
+ {' "tags": ['}
+
+ {' { "name": "" },'}
+
+ {' { "name": "" },'}
+
+ {' ... etc.'}
+
+ {' ]'}
+
+ {'}'}
+
+
+
+
+
+ {prehost}
+
+
+
+
+
+
+ >
+ );
+});
+
+const TaggingServiceParallelRequests = observer(() => {
+ const { uiStore } = useStore();
+
+ const handleChange = (event: React.ChangeEvent) => {
+ const value = Number(event.target.value);
+ uiStore.setTaggingServiceParallelRequests(value);
+ };
+
+ return (
+
+ Number of Tagging Requests in Parallel
+
+ {[...Array(UiStore.MAX_TAGGING_SERVICE_PARALLEL_REQUESTS)].map((_, i) => (
+
+ {i + 1}
+
+ ))}
+
+
+ );
+});
diff --git a/src/frontend/entities/File.ts b/src/frontend/entities/File.ts
index ad06ea33..1ab4f09e 100644
--- a/src/frontend/entities/File.ts
+++ b/src/frontend/entities/File.ts
@@ -121,6 +121,10 @@ export class ClientFile {
makeObservable(this);
}
+ get isAutoSaveEnabled(): boolean {
+ return this.autoSave;
+ }
+
/**
* Gets his tags and all inherithed tags from parent and implied tags from his tags.
*/
diff --git a/src/frontend/stores/FileStore.ts b/src/frontend/stores/FileStore.ts
index d4de18e4..5c7af178 100644
--- a/src/frontend/stores/FileStore.ts
+++ b/src/frontend/stores/FileStore.ts
@@ -27,6 +27,7 @@ import {
} from 'src/api/extraProperty';
import { InheritedTagsVisibilityModeType } from './UiStore';
import { clamp } from 'common/core';
+import { RendererMessenger } from 'src/ipc/renderer';
export const FILE_STORAGE_KEY = 'Allusion_File';
@@ -74,7 +75,7 @@ class FileStore {
private filesToSave: Map = new Map();
private pendingSaves: number = 0;
@observable isSaving: boolean = false;
-
+ isTaggingWithService: boolean = false;
/** The origin of the current files that are shown */
@observable private content: Content = Content.All;
@observable orderDirection: OrderDirection = OrderDirection.Desc;
@@ -274,6 +275,184 @@ class FileStore {
}
}
+ @action.bound async tagFileUsingNamesOrAliases(file: ClientFile, tags: string[]): Promise {
+ const tagStore = this.rootStore.tagStore;
+ const root = tagStore.root;
+ const matches: ClientTag[] = [];
+ for (const tag of tags) {
+ let match = tagStore.findByNameOrAlias(tag);
+ if (match === undefined) {
+ match = await tagStore.create(root, tag);
+ }
+ // First collect all matches in an array instead of directly adding them to
+ // the file, to avoid unnecessary backend saves while awaiting the creation of tags.
+ matches.push(match);
+ }
+ file.addTags(matches);
+ }
+
+ @action.bound private async getTagNamesUsingTaggingService(file: ClientFile) {
+ const taggingServiceURL = this.rootStore.uiStore.taggingServiceURL;
+ const response = await fetch(taggingServiceURL, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ file: file.absolutePath }),
+ }).catch((error) => {
+ throw new Error(`Error while performing the request to the tagging service: ${error}`);
+ });
+
+ // Process the response. If there are errors, show them in the console.
+ // but do not throw to allow the rest of the files to be processed.
+ if (response.ok) {
+ // successful request
+ try {
+ const responseData = await response.json();
+ if (responseData.tags && Array.isArray(responseData.tags)) {
+ const tagNames: string[] = responseData.tags
+ .map((tag: any) => tag?.name)
+ .filter((name: string | undefined): name is string => name !== undefined);
+ if (tagNames.length === 0) {
+ console.warn('Possible invalid tag data format: no "name" found.');
+ return;
+ }
+ return tagNames;
+ } else if (responseData.error) {
+ console.error('Tagging service response error: ', responseData.error);
+ } else {
+ console.error(
+ 'Tagging service error: no tags found or invalid tag data format. ' +
+ 'The response must contain a "tags" key, containing a an array of objects, each with a "name" key.',
+ );
+ }
+ } catch (error) {
+ // catch .json() errors
+ console.error(
+ 'Tagging service error: The response must be JSON containing' +
+ ' a "tags" key, containing a an array of objects, each with a "name" key.',
+ error,
+ );
+ }
+ } else {
+ let errorBody;
+ try {
+ errorBody = await response.clone().json();
+ } catch {
+ errorBody = await response.text();
+ }
+ console.error('Response error:', response.status, errorBody);
+ }
+ }
+
+ @action.bound async tagSelectedFilesUsingTaggingService(): Promise {
+ if (this.isTaggingWithService) {
+ return;
+ }
+ this.isTaggingWithService = true;
+ const taggingServiceURL = this.rootStore.uiStore.taggingServiceURL;
+ const files = Array.from(this.rootStore.uiStore.fileSelection);
+ const numFiles = files.length;
+ const isMulti = numFiles > 1;
+ let successCount = 0;
+ let isServiceActive = false;
+ const toastKey = 'tagging-using-service';
+ let isCancelled = false;
+ const clickAction = { label: 'Open DevTools', onClick: RendererMessenger.toggleDevTools };
+ const showProgressToaster = (progress: number) => {
+ if (!isCancelled) {
+ const progressCount = Math.round(numFiles * progress);
+ AppToaster.show(
+ {
+ message: `Tagging ${numFiles} file${isMulti ? 's ' : ''}${
+ isMulti ? (progress * 100).toFixed(1) : ''
+ }${isMulti ? '%...' : '...'} ${
+ successCount !== progressCount ? `(${progressCount - successCount} failures)` : ''
+ }`,
+ timeout: 0,
+ clickAction: {
+ label: 'Cancel',
+ onClick: () => {
+ isCancelled = true;
+ },
+ },
+ },
+ toastKey,
+ );
+ }
+ };
+
+ showProgressToaster(0);
+ // Process files with only N jobs in parallel and a progress + cancel callback
+ const N = this.rootStore.uiStore.taggingServiceParallelRequests;
+ await promiseAllLimit(
+ files.map((file) => async () => {
+ const currentSuccessCount = successCount;
+ const generatedTagNames = await this.getTagNamesUsingTaggingService(file);
+ if (!isCancelled && generatedTagNames !== undefined && generatedTagNames.length > 0) {
+ try {
+ await this.tagFileUsingNamesOrAliases(file, generatedTagNames);
+ // Add a common tag to indicate that the file was auto-tagged, allowing the user to filter them.
+ await this.tagFileUsingNamesOrAliases(file, ['auto-tagged']);
+ // Save the file even if it has been disposed after a change of content view
+ if (!file.isAutoSaveEnabled) {
+ this.save(runInAction(() => file.serialize()));
+ }
+ successCount++;
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ // if not success, allways for the first one but do not spam if all are fails.
+ if (successCount === currentSuccessCount && (!isServiceActive || successCount > 0)) {
+ AppToaster.show(
+ {
+ type: 'error',
+ message: `Failed to get the tags for "${file.name}" from the tagging service`,
+ timeout: 8000,
+ clickAction: clickAction,
+ },
+ `${toastKey}-failed-${file.id}`,
+ );
+ }
+ isServiceActive = true;
+ }),
+ N,
+ showProgressToaster,
+ () => isCancelled,
+ ).catch((e) => console.error(e));
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ if (successCount === 0) {
+ const message = taggingServiceURL
+ ? 'Could not get tags from the tagging service: is it not running, or is the API/URL misconfigured?'
+ : 'No Local Tagging Service API configured, go to: Settings > Background Processes > Local Tagging Service API URL';
+ AppToaster.show(
+ {
+ type: 'error',
+ message: message,
+ timeout: 10000,
+ clickAction: taggingServiceURL ? clickAction : undefined,
+ },
+ toastKey,
+ );
+ } else {
+ const isSuccess = successCount === numFiles;
+ AppToaster.show(
+ {
+ type: isSuccess ? 'success' : 'warning',
+ message: `Successfully tagged ${successCount} of ${numFiles} files. ${
+ !isSuccess ? `(${numFiles - successCount} failures)` : ''
+ }`,
+ timeout: 0,
+ clickAction: !isSuccess ? clickAction : undefined,
+ },
+ toastKey,
+ );
+ }
+ this.isTaggingWithService = false;
+ }
+
get InheritedTagsVisibilityMode(): InheritedTagsVisibilityModeType {
return this.rootStore.uiStore.inheritedTagsVisibilityMode;
}
@@ -698,6 +877,9 @@ class FileStore {
// Removes all items from fileList
@action.bound clearFileList(): void {
+ for (const file of this.fileList) {
+ file?.dispose();
+ }
this.numLoadedFiles = 0;
this.fileDimensions.clear();
this.fileList.clear();
@@ -761,6 +943,7 @@ class FileStore {
getLocation(location: ID): ClientLocation {
const loc = this.rootStore.locationStore.get(location);
if (!loc) {
+ this.backend.removeLocation(location);
throw new Error(
`Location of file was not found! This should never happen! Location ${location}`,
);
diff --git a/src/frontend/stores/UiStore.ts b/src/frontend/stores/UiStore.ts
index 89ced40a..94f5328c 100644
--- a/src/frontend/stores/UiStore.ts
+++ b/src/frontend/stores/UiStore.ts
@@ -136,6 +136,8 @@ type PersistentPreferenceFields =
| 'isFileTagsEditorOpen'
| 'isFileExtraPropertiesEditorOpen'
| 'thumbnailDirectory'
+ | 'taggingServiceURL'
+ | 'taggingServiceParallelRequests'
| 'importDirectory'
| 'method'
| 'thumbnailSize'
@@ -168,6 +170,7 @@ class UiStore {
static MIN_OUTLINER_WIDTH = 192; // default of 12 rem
static MIN_INSPECTOR_WIDTH = 288; // default of 18 rem
static MAX_RECENTLY_USED_TAGS = 40;
+ static MAX_TAGGING_SERVICE_PARALLEL_REQUESTS = 10;
private readonly rootStore: RootStore;
@@ -250,6 +253,9 @@ class UiStore {
@observable thumbnailDirectory: string = '';
@observable importDirectory: string = ''; // for browser extension. Must be a (sub-folder of a) Location
+ @observable taggingServiceURL: string = '';
+ @observable taggingServiceParallelRequests: number = 4;
+
@observable readonly hotkeyMap: IHotkeyMap = observable(defaultHotkeyMap);
constructor(rootStore: RootStore) {
@@ -714,6 +720,18 @@ class UiStore {
this.thumbnailDirectory = dir;
}
+ @action.bound setTaggingServiceURL(url: string = ''): void {
+ this.taggingServiceURL = encodeURI(url);
+ }
+
+ @action.bound setTaggingServiceParallelRequests(value: number = 1): void {
+ this.taggingServiceParallelRequests = clamp(
+ value,
+ 1,
+ UiStore.MAX_TAGGING_SERVICE_PARALLEL_REQUESTS,
+ );
+ }
+
@action.bound setImportDirectory(dir: string): void {
this.importDirectory = dir;
}
@@ -1320,6 +1338,12 @@ class UiStore {
if (prefs.thumbnailDirectory) {
this.setThumbnailDirectory(prefs.thumbnailDirectory);
}
+ if (prefs.taggingServiceURL) {
+ this.setTaggingServiceURL(prefs.taggingServiceURL);
+ }
+ if ('taggingServiceParallelRequests' in prefs) {
+ this.setTaggingServiceParallelRequests(prefs.taggingServiceParallelRequests);
+ }
if (prefs.importDirectory) {
this.setImportDirectory(prefs.importDirectory);
}
@@ -1421,6 +1445,8 @@ class UiStore {
isFileTagsEditorOpen: this.isFileTagsEditorOpen,
isFileExtraPropertiesEditorOpen: this.isFileExtraPropertiesEditorOpen,
thumbnailDirectory: this.thumbnailDirectory,
+ taggingServiceURL: this.taggingServiceURL,
+ taggingServiceParallelRequests: this.taggingServiceParallelRequests,
importDirectory: this.importDirectory,
method: this.method,
thumbnailSize: this.thumbnailSize,