diff --git a/README.md b/README.md index 7fd5c098b..481b357dd 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Fork of [Allusion](https://github.com/allusion-app/Allusion/) with: - Video support. - Video and GIF playback options. +- Bulk tagging. - Implied tag relationships. - Automatic inheritance of implied tags. - Fully compatible with advanced search. @@ -23,7 +24,7 @@ Fork of [Allusion](https://github.com/allusion-app/Allusion/) with: - Find tags using the contextual menu in the tag editor. - Adjustable padding for thumbnails in the gallery. - Multiple optimizations and bug fixes. - - And more — see the changelogs in the releases page for more details. + - And more, see the changelogs in the releases page for more details. Thanks to the developers and the community for your hard work! ❤️ diff --git a/resources/style/inspector.scss b/resources/style/inspector.scss index c50d8d701..3bf11239b 100644 --- a/resources/style/inspector.scss +++ b/resources/style/inspector.scss @@ -89,7 +89,7 @@ padding-right: 0.2rem; } - #inspector-extra-porperties-header { + .inspector-extra-porperties-header { .toolbar-button { opacity: 0; @@ -100,11 +100,11 @@ } } - #inspector-extra-porperties-header > div[tabindex='-1'] > .toolbar-button { + .inspector-extra-porperties-header > div[tabindex='-1'] > .toolbar-button { justify-content: flex-end; } - &:hover #inspector-extra-porperties-header .toolbar-button { + &:hover .inspector-extra-porperties-header .toolbar-button { opacity: 1; } } diff --git a/resources/style/outliner.scss b/resources/style/outliner.scss index 3e10d3148..544aafb7e 100644 --- a/resources/style/outliner.scss +++ b/resources/style/outliner.scss @@ -239,10 +239,12 @@ margin-top: 0.25rem; } +.location-properties-dialog, .tag-properties-dialog { display: flex; flex-direction: column; + #location-properties-dialog-content, #tag-properties-dialog-content { width: min(90ch, 80vw); max-height: 80vh; diff --git a/src/api/data-storage.ts b/src/api/data-storage.ts index 11c09161e..23bf132ec 100644 --- a/src/api/data-storage.ts +++ b/src/api/data-storage.ts @@ -81,6 +81,7 @@ export interface DataStorage { ): Promise>; clear(): Promise; setSeed(seed?: number): Promise; + optimizeDatabase(): Promise; } export function makeFileBatchFetcher( diff --git a/src/api/file.ts b/src/api/file.ts index f2459a797..3439b7838 100644 --- a/src/api/file.ts +++ b/src/api/file.ts @@ -59,6 +59,7 @@ export const IMG_EXTENSIONS = [ 'jpeg', 'jfif', 'webp', + 'avif', 'tif', 'tiff', 'bmp', diff --git a/src/api/location.ts b/src/api/location.ts index fe1bd155b..ef430b2a2 100644 --- a/src/api/location.ts +++ b/src/api/location.ts @@ -12,7 +12,7 @@ export type LocationDTO = { export type SubLocationDTO = { id: ID; - name: string; + path: string; isExcluded: boolean; subLocations: SubLocationDTO[]; tags: ID[]; diff --git a/src/api/tag.ts b/src/api/tag.ts index bc0f1aa92..a422b2285 100644 --- a/src/api/tag.ts +++ b/src/api/tag.ts @@ -1,6 +1,7 @@ import { ID } from './id'; export const ROOT_TAG_ID = 'root'; +export const ROOT_LOCATIONS_TAG_ID = 'locations_root'; export type TagDTO = { id: ID; diff --git a/src/backend/backend.ts b/src/backend/backend.ts index d3acd1349..7d2965168 100644 --- a/src/backend/backend.ts +++ b/src/backend/backend.ts @@ -61,7 +61,7 @@ import { FileDTO, FileStats } from 'src/api/file'; import { FileSearchDTO, SearchGroupDTO } from 'src/api/file-search'; import { generateId, ID } from 'src/api/id'; import { LocationDTO, SubLocationDTO } from 'src/api/location'; -import { ROOT_TAG_ID, TagDTO } from 'src/api/tag'; +import { ROOT_LOCATIONS_TAG_ID, ROOT_TAG_ID, TagDTO } from 'src/api/tag'; import { jsonArrayFrom } from 'kysely/helpers/sqlite'; import { IS_DEV } from 'common/process'; import { UpdateObject } from 'kysely/dist/cjs/parser/update-set-parser'; @@ -136,7 +136,7 @@ export default class Backend implements DataStorage { await sql`PRAGMA cache_size = -64000;`.execute(db); await sql`PRAGMA OPTIMIZE;`.execute(db); - // Create Root Tag if not exists. + // Create Root Tags if not exists. const rootTag = await db .selectFrom('tags') .selectAll() @@ -159,6 +159,29 @@ export default class Backend implements DataStorage { }) .execute(); } + const rootLocationTag = await db + .selectFrom('tags') + .selectAll() + .where('id', '=', ROOT_LOCATIONS_TAG_ID) + .executeTakeFirst(); + if (!rootLocationTag) { + await db + .insertInto('tags') + .values({ + id: ROOT_LOCATIONS_TAG_ID, + name: 'Root Locations Tag', + dateAdded: serializeDate(new Date()), + color: '', + isHidden: serializeBoolean(false), + isVisibleInherited: serializeBoolean(false), + description: '', + isHeader: serializeBoolean(false), + fileCount: 0, + isFileCountDirty: serializeBoolean(true), + }) + .execute(); + } + await this.preAggregateJSON(); } @@ -230,7 +253,17 @@ export default class Backend implements DataStorage { SELECT file_id, json_group_array(tag_id) AS tags - FROM file_tags + FROM ( + -- Source A: Explicit tags + SELECT file_id, tag_id FROM file_tags + UNION ALL + -- Source B: Virtual location tags driven by the directory_path index + SELECT + f.id AS file_id, + l.id AS tag_id + FROM files f + INNER JOIN location_nodes l ON l.path = f.directory_path + ) GROUP BY file_id; `.execute(this.#db); await sql` @@ -409,7 +442,7 @@ export default class Backend implements DataStorage { // convert data into SubLocationDTO format const slc: SubLocationDTO = { id: dbLoc.id, - name: dbLoc.path, + path: dbLoc.path, subLocations: [], tags: dbLoc.tags.map((t) => t.tagId), isExcluded: deserializeBoolean(dbLoc.isExcluded), @@ -632,9 +665,11 @@ export default class Backend implements DataStorage { try { // Create temp tables form a copy of the actual tables. - await sql`CREATE TEMP TABLE ${sql.id(tempFiles)} AS SELECT * FROM files WHERE 0`.execute( - trx, - ); + const fileKeys = Object.keys(files[0]); + const fileColumns = sql.join(fileKeys.map((key) => sql.ref(key as string))); + await sql`CREATE TEMP TABLE ${sql.id( + tempFiles, + )} AS SELECT ${fileColumns} FROM files WHERE 0`.execute(trx); await sql`CREATE TEMP TABLE ${sql.id( tempFileTags, )} AS SELECT * FROM file_tags WHERE 0`.execute(trx); @@ -847,6 +882,13 @@ export default class Backend implements DataStorage { this.#notifyChange(); } + async optimizeDatabase(): Promise { + console.info('SQLite: Optimize database and free space...'); + await sql`PRAGMA optimize;`.execute(this.#db); + await sql`VACUUM;`.execute(this.#db); + this.#notifyChange(); + } + async removeSearch(search: ID): Promise { console.info('SQLite: Removing search...', search); // Cascade delte in other tables deleting from savedSearches table. @@ -1657,11 +1699,24 @@ function applyTagArrayCondition( .select('fileId') .where('tagId', 'in', values) .distinct(); + const matchingFilesByLocationTag = eb + .selectFrom('files') + .innerJoin('locationNodes', 'locationNodes.path', 'files.directoryPath') + .select('files.id as fileId') + .where('locationNodes.id', 'in', values) // Location Tag Ids are the same as the locationNode id + .distinct(); + if (operator === 'contains') { - return eb('files.id', 'in', matchingFiles); + return eb.or([ + eb('files.id', 'in', matchingFiles), + eb('files.id', 'in', matchingFilesByLocationTag), + ]); } else { // notContains: ensure NOT EXISTS any tag in the list for that file - return eb.not(eb('files.id', 'in', matchingFiles)); + return eb.and([ + eb.not(eb('files.id', 'in', matchingFiles)), + eb.not(eb('files.id', 'in', matchingFilesByLocationTag)), + ]); } } } @@ -1791,6 +1846,7 @@ async function upsertTable< `sampleObject is required when using SQL expressions for table ${String(table)}`, ); } + const fileKeys = Object.keys(referenceRow); const columnsToUpdate = Object.keys(referenceRow).filter( (key) => !conflictColumns.includes(key as any) && @@ -1803,7 +1859,10 @@ async function upsertTable< let query; if (isExpression) { - query = db.insertInto(table as keyof AllusionDB_SQL & string).expression(values as any); + query = db + .insertInto(table as keyof AllusionDB_SQL & string) + .columns(fileKeys as any) + .expression(values as any); } else { query = db.insertInto(table as keyof AllusionDB_SQL & string); } @@ -1883,7 +1942,7 @@ function normalizeLocations(sourcelocations: LocationDTO[]) { isRoot: boolean, ) { const parentIdvalue = isRoot ? null : parentId; - const pathValue = 'path' in node ? node.path : node.name; + const pathValue = node.path; nodeIds.push(node.id); locationNodes.push({ id: node.id, diff --git a/src/backend/config.ts b/src/backend/config.ts index 281f6b3b5..0a299b171 100644 --- a/src/backend/config.ts +++ b/src/backend/config.ts @@ -25,6 +25,7 @@ class InlineMigrationProvider implements MigrationProvider { return { '000_initial': await import('./migrations/000_initial'), '001_migrateJSON': (await import('./migrations/001_migrateJSON')).default(context), + '002_files_add_generated_directory_path': await import('./migrations/002_files_add_generated_directory_path'), // eslint-disable-line prettier/prettier }; } } diff --git a/src/backend/migrations/002_files_add_generated_directory_path.ts b/src/backend/migrations/002_files_add_generated_directory_path.ts new file mode 100644 index 000000000..8bb0f375b --- /dev/null +++ b/src/backend/migrations/002_files_add_generated_directory_path.ts @@ -0,0 +1,23 @@ +/* eslint-disable prettier/prettier */ +import { Kysely, sql } from 'kysely'; + +/** + * Migration to add a generated virtual directory column and its index + * to the files table, optimizing location tag aggregation. + */ +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('files') + .addColumn('directory_path', 'text', (col) => + col.generatedAlwaysAs(sql`SUBSTR(absolute_path, 1, LENGTH(absolute_path) - LENGTH(name) - 1)`) + ) + .execute(); + + // create index + await db.schema.createIndex('idx_files_directory_path').on('files').column('directory_path').execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex('idx_files_directory_path').execute(); + await db.schema.alterTable('files').dropColumn('directory_path').execute(); +} \ No newline at end of file diff --git a/src/backend/schemaTypes.ts b/src/backend/schemaTypes.ts index 688e64304..9ddceb1a5 100644 --- a/src/backend/schemaTypes.ts +++ b/src/backend/schemaTypes.ts @@ -120,6 +120,9 @@ export type Files = { width: number; height: number; dateCreated: DateAsNumber; + + /** Auto generated by SQLite from absolute_path. Read-only field backed by idx_files_directory_path. */ + directoryPath: ColumnType; }; export type FileTags = { diff --git a/src/frontend/components/FileExtraPropertiesEditor.tsx b/src/frontend/components/FileExtraPropertiesEditor.tsx index 9d90e7f1e..f4d297b19 100644 --- a/src/frontend/components/FileExtraPropertiesEditor.tsx +++ b/src/frontend/components/FileExtraPropertiesEditor.tsx @@ -54,14 +54,6 @@ export const FileExtraPropertiesEditor = observer( editableNode: undefined, }); - /*useEffect(() => { - runInAction(() => { - if (file && uiStore.fileSelection.size < 1) { - uiStore.selectFile(file); - } - }); - }, [file, uiStore]);*/ - const counter: ExtraPropertiesCounter = useComputed(() => { //Map of Clientstores: and a tuple of count, value const counter = new Map(); diff --git a/src/frontend/components/FileTagsEditor.tsx b/src/frontend/components/FileTagsEditor/index.tsx similarity index 66% rename from src/frontend/components/FileTagsEditor.tsx rename to src/frontend/components/FileTagsEditor/index.tsx index cc5c6b4f6..2c51f5ac2 100644 --- a/src/frontend/components/FileTagsEditor.tsx +++ b/src/frontend/components/FileTagsEditor/index.tsx @@ -1,4 +1,4 @@ -import { action, computed, IComputedValue } from 'mobx'; +import { IComputedValue } from 'mobx'; import { observer } from 'mobx-react-lite'; import React, { ForwardedRef, @@ -13,29 +13,23 @@ import React, { import { debounce } from 'common/timeout'; import { Tag } from 'widgets'; import { - Row, RowSeparator, useVirtualizedGridFocus, VirtualizedGrid, VirtualizedGridHandle, VirtualizedGridRowProps, } from 'widgets/combobox/Grid'; -import { IconSet } from 'widgets/icons'; -import { - createGetTabMatchTagCallback, - createTagRowRenderer, - GetTabMatchTag, - isTagSelected, - useTabTagAutocomplete, -} from './TagSelector'; -import { useStore } from '../contexts/StoreContext'; -import { ClientTag } from '../entities/Tag'; -import { useAction, useAutorun, useComputed } from '../hooks/mobx'; +import { GetTabMatchTag, useTabTagAutocomplete } from '../TagSelector'; +import { useStore } from '../../contexts/StoreContext'; +import { ClientTag } from '../../entities/Tag'; +import { useAction, useAutorun, useComputed } from '../../hooks/mobx'; import { Menu, useContextMenu } from 'widgets/menus'; -import { EditorTagSummaryItems } from '../containers/ContentView/menu-items'; +import { EditorTagSummaryItems } from '../../containers/ContentView/menu-items'; import { useGalleryInputKeydownHandler } from 'src/frontend/hooks/useHandleInputKeydown'; -import { normalizeBase } from 'common/core'; -import useFocusEnforcer from '../hooks/useFocusEnforcer'; +import useFocusEnforcer from '../../hooks/useFocusEnforcer'; +import { BULK_APPLY_OPTION, CREATE_OPTION } from './specialOptions'; +import useNormaltaggingMode from './useNormaltaggingMode'; +import useBulkTaggingMode, { BulkTag, isBulkText } from './useBulkTaggingMode'; const POPUP_ID = 'tag-editor-popup'; const PANEL_SIZE_ID = 'tag-editor-height'; @@ -99,6 +93,16 @@ export const FileTagsEditor = observer(() => { setInputText(e.target.value), ).current; + // this callback transforms line breaks when pasting for bulk + const handlePaste = useRef((e: React.ClipboardEvent) => { + const pastedText = e.clipboardData.getData('text'); + if (/[\n\r,]/.test(pastedText)) { + e.preventDefault(); // prevent singleline handleInput + const flattenedText = pastedText.replace(/[\n\r]+/g, ', ').trim(); + setInputText(flattenedText); + } + }).current; + const getTabMatchTagRef = useRef(() => undefined); const gridRef = useRef(null); const [activeDescendant, handleGridFocus] = useVirtualizedGridFocus(gridRef); @@ -197,15 +201,18 @@ export const FileTagsEditor = observer(() => { }; }, []); - const resetTextBox = useCallback(() => { - inputRef.current?.focus(); - if (clearInputOnSelect) { - setInputText(''); - } else { - inputRef.current?.select(); - } - inputRef.current?.focus(); - }, [clearInputOnSelect]); + const resetTextBox = useCallback( + (force?: boolean) => { + inputRef.current?.focus(); + if (clearInputOnSelect || force) { + setInputText(''); + } else { + inputRef.current?.select(); + } + inputRef.current?.focus(); + }, + [clearInputOnSelect], + ); const removeTag = useAction(async (tag: ClientTag) => { await uiStore.removeTagsFromSelectedFiles([tag]); @@ -236,6 +243,7 @@ export const FileTagsEditor = observer(() => { value={inputText} aria-autocomplete="list" onChange={handleInput} + onPaste={handlePaste} onKeyDown={handleKeyDown} className="input" aria-controls={POPUP_ID} @@ -267,13 +275,11 @@ export const FileTagsEditor = observer(() => { ); }); -export const CREATE_OPTION = Symbol('tag_create_option'); - interface MatchingTagsListProps { inputText: string; getTabMatchTagRef: React.MutableRefObject; counter: IComputedValue>; - resetTextBox: () => void; + resetTextBox: (force?: boolean) => void; onContextMenu?: (e: React.MouseEvent, tag: ClientTag) => void; } @@ -282,158 +288,54 @@ const MatchingTagsList = observer( { inputText, counter, resetTextBox, onContextMenu, getTabMatchTagRef }: MatchingTagsListProps, ref: ForwardedRef, ) { - const { tagStore, uiStore } = useStore(); - - const { matches, widestItem } = useMemo( - () => - computed(() => { - if (inputText.length === 0) { - let widest: ClientTag | undefined = undefined; - // string matches creates separators - const matches: (symbol | ClientTag | string)[] = []; - // Add recently used tags. - if (uiStore.recentlyUsedTags.length > 0) { - matches.push('Recently used tags'); - for (const tag of uiStore.recentlyUsedTags) { - matches.push(tag); - widest = widest ? (tag.pathCharLength > widest.pathCharLength ? tag : widest) : tag; - } - if (counter.get().size > 0) { - matches.push('Assigned tags'); - } - } - for (const tag of counter.get().keys()) { - matches.push(tag); - widest = widest ? (tag.pathCharLength > widest.pathCharLength ? tag : widest) : tag; - } - // Always append CREATE_OPTION to render the create option component. - matches.push(CREATE_OPTION); - return { matches: matches, widestItem: widest }; - } else { - let widest: ClientTag | undefined = undefined; - const normalizedInput = normalizeBase(inputText); - const exactMatches: ClientTag[] = []; - const otherMatches: ClientTag[] = []; - for (const tag of tagStore.tagList) { - const match = tag.isMatch(normalizedInput); - if (match === 1) { - exactMatches.push(tag); - } else if (match === 2) { - otherMatches.push(tag); - } - if (match > 0) { - widest = widest ? (tag.pathCharLength > widest.pathCharLength ? tag : widest) : tag; - } - } - // Bring exact matches to the top of the suggestions. This helps find tags with short names - // that would otherwise get buried under partial matches if they appeared lower in the list. - // Always append CREATE_OPTION to render the create option component. - const createOptionMatches = - exactMatches.length > 0 || otherMatches.length > 0 - ? ['', CREATE_OPTION] - : [CREATE_OPTION]; - return { - matches: [...exactMatches, ...otherMatches, ...createOptionMatches], - widestItem: widest, - }; - } - }), - [counter, inputText, tagStore.tagList, uiStore.recentlyUsedTags], - ).get(); - - useEffect(() => { - getTabMatchTagRef.current = createGetTabMatchTagCallback(matches); - for (const posibleTag of matches) { - if (posibleTag instanceof ClientTag) { - posibleTag.shiftAliasToFront(); - } - } - }, [getTabMatchTagRef, matches]); - - // When selecting all filles there's no way to know the true selected statos so instead - // we use a map to track the checked status. - // reset it using usingmemo each time isAllFilesSelected changes - const allSelectedToggleStatus = useMemo(() => { - if (uiStore.isAllFilesSelected) { - return new Map(); - } else { - return undefined; - } - }, [uiStore.isAllFilesSelected]); + const isBulkMode = useMemo(() => isBulkText(inputText), [inputText]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const toggleSelection = useCallback( - action(async (isSelected: boolean, tag: ClientTag) => { - resetTextBox(); - if (isSelected) { - allSelectedToggleStatus?.set(tag.id, false); - await uiStore.removeTagsFromSelectedFiles([tag]); - } else { - allSelectedToggleStatus?.set(tag.id, true); - await uiStore.addTagsToSelectedFiles([tag]); - } - }), - [resetTextBox, allSelectedToggleStatus], - ); + const bulkC = useBulkTaggingMode({ + active: isBulkMode, + inputText, + popupId: POPUP_ID, + resetTextBox, + }); + const normalC = useNormaltaggingMode({ + active: !isBulkMode, + inputText, + popupId: POPUP_ID, + counter, + getTabMatchTagRef, + resetTextBox, + onContextMenu, + }); - const isSelected: isTagSelected = useCallback( - // define the selected satus: - // - if any file has it, mark it as explicit - // - if not all selected files have the tag or is selecting all filtered - // files and its allSelectedToggleStatus is false, mark it as partial - (tag: ClientTag) => { - const tagRecord = counter.get().get(tag); - const isExplicit = tagRecord?.[1] ?? false; - const isPartial = - tagRecord?.[0] !== uiStore.fileSelection.size || - (allSelectedToggleStatus && !allSelectedToggleStatus.get(tag.id)); - return [tagRecord !== undefined && !isPartial, isExplicit]; - }, - [allSelectedToggleStatus, counter, uiStore], - ); - const VirtualizableTagOption = useMemo( - () => - observer( - createTagRowRenderer({ - id: POPUP_ID, - isSelected: isSelected, - toggleSelection: toggleSelection, - onContextMenu: onContextMenu, - }), - ), - [isSelected, onContextMenu, toggleSelection], - ); - const VirtualizableCreateOption = useMemo(() => { - const VirtualizableCreateOption = ({ index, style }: VirtualizedGridRowProps) => { - return ( - 1} - resetTextBox={resetTextBox} - /> - ); - }; - return VirtualizableCreateOption; - }, [inputText, matches.length, resetTextBox]); + const VirtualizableCreateOption = normalC.VirtualizableCreateOption; + const VirtualBulkApplyOption = bulkC.VirtualizableBulkApplyOption; + const VirtualizableTagOption = normalC.VirtualizableTagOption; + const VirtualizableBulkTagOption = bulkC.VirtualizableTagOption; + const matches = isBulkMode ? bulkC.matches : normalC.matches; + const widestItem = isBulkMode ? bulkC.widestItem : normalC.widestItem; const row = useMemo(() => { - const row = (rowProps: VirtualizedGridRowProps) => { + const row = (rowProps: VirtualizedGridRowProps) => { const item = rowProps.data[rowProps.index]; if (item === CREATE_OPTION) { return )} />; + } else if (item === BULK_APPLY_OPTION) { + return )} />; } else if (typeof item === 'string') { const { position, top, height } = rowProps.style ?? {}; return ; - } else { + } else if (item instanceof ClientTag) { return )} />; + } else { + return )} />; } }; return row; - }, [VirtualizableCreateOption, VirtualizableTagOption]); + }, [ + VirtualBulkApplyOption, + VirtualizableBulkTagOption, + VirtualizableCreateOption, + VirtualizableTagOption, + ]); return ( void; - style?: React.CSSProperties | undefined; - index?: number; -} - -const CreateOption = ({ inputText, hasMatches, resetTextBox, style, index }: CreateOptionProps) => { - const { tagStore, uiStore } = useStore(); - - const createTag = useCallback(async () => { - const newTag = await tagStore.create(tagStore.root, inputText); - await uiStore.addTagsToSelectedFiles([newTag]); - resetTextBox(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [inputText, resetTextBox]); - - return ( - <> - {inputText.length > 0 ? ( - <> - - - ) : ( - !hasMatches && ( - - ) - )} - - ); -}; - interface TagSummaryProps { counter: IComputedValue>; removeTag: (tag: ClientTag) => void; diff --git a/src/frontend/components/FileTagsEditor/specialOptions.tsx b/src/frontend/components/FileTagsEditor/specialOptions.tsx new file mode 100644 index 000000000..eeee380b2 --- /dev/null +++ b/src/frontend/components/FileTagsEditor/specialOptions.tsx @@ -0,0 +1,113 @@ +import React, { useCallback } from 'react'; +import { useStore } from 'src/frontend/contexts/StoreContext'; +import { Row } from 'widgets/combobox'; +import { IconSet } from 'widgets/icons'; +import { ClientTag } from 'src/frontend/entities/Tag'; +import { runInAction } from 'mobx'; + +export const CREATE_OPTION = Symbol('tag_create_option'); + +interface CreateOptionProps { + inputText: string; + hasMatches: boolean; + resetTextBox: () => void; + style?: React.CSSProperties | undefined; + index?: number; +} + +export const CreateOption = ({ + inputText, + hasMatches, + resetTextBox, + style, + index, +}: CreateOptionProps) => { + const { tagStore, uiStore } = useStore(); + + const createTag = useCallback(async () => { + const newTag = await tagStore.create(tagStore.root, inputText); + await uiStore.addTagsToSelectedFiles([newTag]); + resetTextBox(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inputText, resetTextBox]); + + return ( + <> + {inputText.length > 0 ? ( + <> + + + ) : ( + !hasMatches && ( + + ) + )} + + ); +}; + +export const BULK_APPLY_OPTION = Symbol('tag_bulk_apply_option'); // 'Detected Tags (Click this to apply all selected)' + +interface BulkApplyOptionProps { + inputText: string; + tagNames: string[]; + resetTextBox: (force?: boolean) => void; + style?: React.CSSProperties | undefined; + index?: number; +} + +export const BulkApplyOption = ({ + inputText, + tagNames, + resetTextBox, + style, + index, +}: BulkApplyOptionProps) => { + const { tagStore, uiStore } = useStore(); + + const applytags = useCallback(async () => { + const root = runInAction(() => tagStore.root); + const tagMatches = new Set(); + for (const tagName of tagNames) { + let match = tagStore.findByNameOrAlias(tagName); + if (match === undefined) { + match = await tagStore.create(root, tagName); + } + // First collect all matches in an set instead of directly adding them to + // the file, to avoid unnecessary backend saves while awaiting the creation of tags. + tagMatches.add(match); + } + await uiStore.addTagsToSelectedFiles(Array.from(tagMatches)); + resetTextBox(true); + }, [resetTextBox, tagNames, tagStore, uiStore]); + + return ( + <> + {inputText.length > 0 ? ( + <> + + + ) : ( + tagNames.length <= 0 && ( + + ) + )} + + ); +}; diff --git a/src/frontend/components/FileTagsEditor/useBulkTaggingMode.tsx b/src/frontend/components/FileTagsEditor/useBulkTaggingMode.tsx new file mode 100644 index 000000000..3a644ff50 --- /dev/null +++ b/src/frontend/components/FileTagsEditor/useBulkTaggingMode.tsx @@ -0,0 +1,242 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { BULK_APPLY_OPTION, BulkApplyOption } from './specialOptions'; +import { GridCell, Row, VirtualizedGridRowProps } from 'widgets/combobox/Grid'; +import { IconSet } from 'widgets/icons'; +import { useStore } from 'src/frontend/contexts/StoreContext'; + +export interface BulkTag { + id: string; + name: string; + pathCharLength: number; +} + +export function parseBulkInput(text: string, stringsToRemove: string[] = []): string[] { + let rawItems: string[] = []; + + try { + const trimmedText = text.trim(); + if (trimmedText.startsWith('[') || trimmedText.startsWith('{')) { + const parsed = JSON.parse(text); + if (Array.isArray(parsed)) { + rawItems = parsed.map((i) => (typeof i === 'string' ? i : i.name || i.tag || '')); + } else if (parsed.tags && Array.isArray(parsed.tags)) { + rawItems = parsed.tags; + } + } + } catch (e) { + // Was not valid JSON, process raw text + } + + // If no items were extracted from JSON, split by common delimiters + if (rawItems.length === 0) { + rawItems = text.split(/[,;\n\r]+/); + } + + const processedItems = rawItems.map((item) => { + let cleaned = item; + + // Replace strings to remove with whitespace + if (stringsToRemove.length > 0) { + // Escape special regex characters to ensure safe replacement if strings contain symbols + const escapedStrings = stringsToRemove.map((s) => + s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'), + ); + const regex = new RegExp(escapedStrings.join('|'), 'g'); + cleaned = cleaned.replace(regex, ' '); + } + + // Remove duplicate spaces and apply trim + return cleaned.replace(/\s+/g, ' ').trim(); + }); + + // Filter out empty strings and remove duplicates + const uniqueItems = Array.from(new Set(processedItems.filter((item) => item.length > 0))); + + const collator = new Intl.Collator(undefined, { + numeric: true, + sensitivity: 'base', + }); + + return uniqueItems.sort(collator.compare); +} + +export const isBulkText = (inputText: string) => { + if (inputText.length === 0) { + return false; + } + // return true if it has common delimiters or looks like a Json + return /[,;\n\r]/.test(inputText) || (inputText.includes('{') && inputText.includes('}')); +}; + +export interface useBulkTaggingModeProps { + active: boolean; + popupId: string; + inputText: string; + resetTextBox: (force?: boolean) => void; +} + +const useBulkTaggingMode = ({ + active, + popupId, + inputText, + resetTextBox, +}: useBulkTaggingModeProps) => { + const { uiStore } = useStore(); + const [selectedMatches, setSelectedMatches] = useState>(new Map()); + + const bulkAutoRemoveStrings = useMemo( + () => uiStore.bulkAutoRemoveStrings.slice(), + [uiStore.bulkAutoRemoveStrings], + ); + const autoDisableBulkTagNames = useMemo( + () => uiStore.autoDisableBulkTagNames.slice(), + [uiStore.autoDisableBulkTagNames], + ); + + const bulkNames = useMemo(() => { + if (!active) { + return []; + } + return parseBulkInput(inputText, bulkAutoRemoveStrings); + }, [active, bulkAutoRemoveStrings, inputText]); + + // set initial values for all bulkNames as true + useEffect(() => { + if (active) { + const newMap = new Map(); + // Separate exact matches from compiled regex patterns + const exactMatchSet = new Set(); + const regexPatterns: RegExp[] = []; + + for (const item of autoDisableBulkTagNames) { + // Check if the string looks like a regex literal (e.g., "/^tag_\d+$/i") + if (item.startsWith('/') && item.lastIndexOf('/') > 0) { + try { + const lastSlashIndex = item.lastIndexOf('/'); + const pattern = item.slice(1, lastSlashIndex); + const flags = item.slice(lastSlashIndex + 1); + regexPatterns.push(new RegExp(pattern, flags)); + } catch (e) { + // If the regex is malformed, treat it as a fallback exact string match + exactMatchSet.add(item); + } + } else { + exactMatchSet.add(item); + } + } + // Evaluate each bulkName against both criteria + for (const name of bulkNames) { + // Check exact match first + let shouldOmit = exactMatchSet.has(name); + // If not omitted yet, test against the compiled regex list + if (!shouldOmit && regexPatterns.length > 0) { + shouldOmit = regexPatterns.some((regex) => regex.test(name)); + } + // If it should be omitted, set to false (unchecked) + newMap.set(name, !shouldOmit); + } + setSelectedMatches(newMap); + } + }, [active, bulkNames, autoDisableBulkTagNames]); + + // Compute "matches" these matches are actually the detected tag names the user can assign + const { matches, widestItem } = useMemo((): { + matches: (string | symbol | BulkTag)[]; + widestItem: BulkTag | undefined; + } => { + if (active) { + let widest: BulkTag | undefined = undefined; + const bulkMatches: BulkTag[] = []; + + for (const name of bulkNames) { + const item: BulkTag = { + id: `bulk-${name}`, + name: name, + pathCharLength: name.length, + }; + bulkMatches.push(item); + widest = widest ? (item.pathCharLength > widest.pathCharLength ? item : widest) : item; + } + + // Add a default option that is also the apply all option. + return { + matches: [BULK_APPLY_OPTION, ...bulkMatches], + widestItem: widest, + }; + } + return { + matches: [], + widestItem: undefined, + }; + }, [active, bulkNames]); + + const toggleBulkSelection = useCallback((name: string) => { + setSelectedMatches((prev) => { + const next = new Map(prev); + // Invierte el estado booleano: si no existía o era false, pasa a true, y viceversa + next.set(name, !next.get(name)); + return next; + }); + }, []); + + const VirtualizableTagOption = useMemo(() => { + const virtualizableTagOption = ({ + index, + style, + data, + id: sub_id, + }: VirtualizedGridRowProps) => { + const item = data[index]; + const checked = selectedMatches.get(item.name) ?? false; + + return ( + {IconSet.TAG}} + onClick={() => toggleBulkSelection(item.name)} + className="tag-option bulk-tag-option" + tooltip={item.name} + > + + + ); + }; + return virtualizableTagOption; + }, [selectedMatches, popupId, toggleBulkSelection]); + + const selectedNames = useMemo( + () => bulkNames.filter((name) => name && selectedMatches.get(name)), + [bulkNames, selectedMatches], + ); + + const VirtualizableBulkApplyOption = useMemo(() => { + const VirtualizableBulkApplyOption = ({ index, style }: VirtualizedGridRowProps) => { + return ( + + ); + }; + return VirtualizableBulkApplyOption; + }, [inputText, resetTextBox, selectedNames]); + + return { + matches, + widestItem, + VirtualizableTagOption, + VirtualizableBulkApplyOption, + }; +}; + +export default useBulkTaggingMode; diff --git a/src/frontend/components/FileTagsEditor/useNormaltaggingMode.tsx b/src/frontend/components/FileTagsEditor/useNormaltaggingMode.tsx new file mode 100644 index 000000000..901e2759a --- /dev/null +++ b/src/frontend/components/FileTagsEditor/useNormaltaggingMode.tsx @@ -0,0 +1,212 @@ +import { normalizeBase } from 'common/core'; +import { action, computed, IComputedValue } from 'mobx'; +import React, { useCallback, useEffect, useMemo } from 'react'; +import { useStore } from 'src/frontend/contexts/StoreContext'; +import { ClientTag } from 'src/frontend/entities/Tag'; +import { + createGetTabMatchTagCallback, + createTagRowRenderer, + GetTabMatchTag, + isTagSelected, +} from '../TagSelector'; +import { observer } from 'mobx-react-lite'; +import { CREATE_OPTION, CreateOption } from './specialOptions'; +import { VirtualizedGridRowProps } from 'widgets/combobox/Grid'; + +export interface useNormaltaggingModeProps { + // if the computations of the hook should be executed, + active: boolean; + popupId: string; + inputText: string; + counter: IComputedValue>; + getTabMatchTagRef: React.MutableRefObject; + resetTextBox: (force?: boolean) => void; + onContextMenu?: (e: React.MouseEvent, tag: ClientTag) => void; +} + +/** + * manages the internal state for normal Tagging mode + * and retunrs the necessary state and callbacks to control the fileTagsEditor + */ +const useNormaltaggingMode = ({ + active, + popupId, + inputText, + counter, + getTabMatchTagRef, + resetTextBox, + onContextMenu, +}: useNormaltaggingModeProps) => { + const { tagStore, uiStore } = useStore(); + + const { matches, widestItem } = useMemo( + () => + computed( + (): { matches: (string | symbol | ClientTag)[]; widestItem: ClientTag | undefined } => { + if (!active) { + uiStore.recentlyUsedTags.length; // dummy oversable read to avoid mobx alerts + return { matches: [], widestItem: undefined }; + } + if (inputText.length === 0) { + let widest: ClientTag | undefined = undefined; + // string matches creates separators + const matches: (symbol | ClientTag | string)[] = []; + // Add recently used tags. + if (uiStore.recentlyUsedTags.length > 0) { + matches.push('Recently used tags'); + for (const tag of uiStore.recentlyUsedTags) { + matches.push(tag); + widest = widest ? (tag.pathCharLength > widest.pathCharLength ? tag : widest) : tag; + } + if (counter.get().size > 0) { + matches.push('Assigned tags'); + } + } + for (const tag of counter.get().keys()) { + matches.push(tag); + widest = widest ? (tag.pathCharLength > widest.pathCharLength ? tag : widest) : tag; + } + // Always append CREATE_OPTION to render the create option component. + matches.push(CREATE_OPTION); + return { matches: matches, widestItem: widest }; + } else { + const includeSubtags = uiStore.isIncludeSubtagsOnMatchEnabled; + let widest: ClientTag | undefined = undefined; + const normalizedInput = normalizeBase(inputText); + const exactMatches: ClientTag[] = []; + const otherMatches: ClientTag[] = []; + const visited = new Set(); + for (const tag of tagStore.tagList) { + if (includeSubtags && visited.has(tag)) { + continue; + } + const match = tag.isMatch(normalizedInput); + if (match > 0) { + let matchTags: Iterable; + const targetArray = match === 1 ? exactMatches : otherMatches; + if (includeSubtags) { + matchTags = tag.getImpliedSubTree(visited); + } else { + matchTags = [tag]; + } + for (const t of matchTags) { + widest = widest ? (t.pathCharLength > widest.pathCharLength ? t : widest) : t; + targetArray.push(t); + } + } + } + // Bring exact matches to the top of the suggestions. This helps find tags with short names + // that would otherwise get buried under partial matches if they appeared lower in the list. + // Always append CREATE_OPTION to render the create option component. + const createOptionMatches = + exactMatches.length > 0 || otherMatches.length > 0 + ? ['', CREATE_OPTION] + : [CREATE_OPTION]; + return { + matches: [...exactMatches, ...otherMatches, ...createOptionMatches], + widestItem: widest, + }; + } + }, + ), + [ + active, + counter, + inputText, + tagStore.tagList, + uiStore.recentlyUsedTags, + uiStore.isIncludeSubtagsOnMatchEnabled, + ], + ).get(); + + useEffect(() => { + getTabMatchTagRef.current = createGetTabMatchTagCallback(matches); + for (const posibleTag of matches) { + if (posibleTag instanceof ClientTag) { + posibleTag.shiftAliasToFront(); + } + } + }, [getTabMatchTagRef, matches]); + + // When selecting all filles there's no way to know the true selected statos so instead + // we use a map to track the checked status. + // reset it using usingmemo each time isAllFilesSelected changes + const allSelectedToggleStatus = useMemo(() => { + if (uiStore.isAllFilesSelected) { + return new Map(); + } else { + return undefined; + } + }, [uiStore.isAllFilesSelected]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const toggleSelection = useCallback( + action(async (isSelected: boolean, tag: ClientTag) => { + resetTextBox(); + if (isSelected) { + allSelectedToggleStatus?.set(tag.id, false); + await uiStore.removeTagsFromSelectedFiles([tag]); + } else { + allSelectedToggleStatus?.set(tag.id, true); + await uiStore.addTagsToSelectedFiles([tag]); + } + }), + [resetTextBox, allSelectedToggleStatus], + ); + + const isSelected: isTagSelected = useCallback( + // define the selected satus: + // - if any file has it, mark it as explicit + // - if not all selected files have the tag or is selecting all filtered + // files and its allSelectedToggleStatus is false, mark it as partial + (tag: ClientTag) => { + const tagRecord = counter.get().get(tag); + const isExplicit = tagRecord?.[1] ?? false; + const isPartial = + tagRecord?.[0] !== uiStore.fileSelection.size || + (allSelectedToggleStatus && !allSelectedToggleStatus.get(tag.id)); + return [tagRecord !== undefined && !isPartial, isExplicit]; + }, + [allSelectedToggleStatus, counter, uiStore], + ); + + const VirtualizableTagOption = useMemo( + () => + observer( + createTagRowRenderer({ + id: popupId, + isSelected: isSelected, + toggleSelection: toggleSelection, + onContextMenu: onContextMenu, + }), + ), + [isSelected, onContextMenu, popupId, toggleSelection], + ); + + const VirtualizableCreateOption = useMemo(() => { + const VirtualizableCreateOption = ({ index, style }: VirtualizedGridRowProps) => { + return ( + 1} + resetTextBox={resetTextBox} + /> + ); + }; + return VirtualizableCreateOption; + }, [inputText, matches.length, resetTextBox]); + + return { + matches, + widestItem, + toggleSelection, + VirtualizableTagOption, + VirtualizableCreateOption, + }; +}; + +export default useNormaltaggingMode; diff --git a/src/frontend/components/StringArrayEditor.tsx b/src/frontend/components/StringArrayEditor.tsx new file mode 100644 index 000000000..72f3bfd69 --- /dev/null +++ b/src/frontend/components/StringArrayEditor.tsx @@ -0,0 +1,80 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Tag } from 'widgets/tag'; + +interface StringArrayEditorProps { + items: string[]; + onAddItem: (value: string) => void; + onEditItem: (value: string, index: number) => void; + onRemoveItem: (index: number) => void; + itemColor?: string; + isHeader?: boolean; + handleBlur?: (e: React.FocusEvent, callback: (val: string) => void) => void; + handleKeyDown?: ( + e: React.KeyboardEvent, + callback: (val: string) => void, + ) => void; +} + +export const StringArrayEditor = ({ + items, + onAddItem, + onEditItem, + onRemoveItem, + itemColor, + isHeader = false, + handleBlur, + handleKeyDown, +}: StringArrayEditorProps) => { + const inputRef = useRef(null); + const [editIndex, setEditIndex] = useState(undefined); + + const handleSubmit = useCallback( + (value: string) => { + if (editIndex !== undefined) { + onEditItem(value, editIndex); + } else { + onAddItem(value); + const input = inputRef.current; + if (input) { + input.value = ''; + } + } + setEditIndex(undefined); + }, + [editIndex, onAddItem, onEditItem], + ); + + useEffect(() => { + const input = inputRef.current; + if (input) { + input.value = editIndex !== undefined ? items[editIndex] : ''; + if (input.value) { + input.focus(); + } + } + }, [editIndex, items]); + + return ( +
+
+ {items.map((item, index) => ( + setEditIndex(index)} + onRemove={() => onRemoveItem(index)} + /> + ))} + handleBlur?.(e, handleSubmit)} + onKeyDown={(e) => handleKeyDown?.(e, handleSubmit)} + /> +
+
+ ); +}; diff --git a/src/frontend/components/TagSelector.tsx b/src/frontend/components/TagSelector.tsx index 46539f47e..65e902761 100644 --- a/src/frontend/components/TagSelector.tsx +++ b/src/frontend/components/TagSelector.tsx @@ -362,27 +362,33 @@ const SuggestedTagsList = observer( } return { suggestions: matches, widestItem: widest }; } else { + const includeSubtags = uiStore.isIncludeSubtagsOnMatchEnabled; let widest: ClientTag | undefined = undefined; const normalizedQuery = normalizeBase(query); const exactMatches: ClientTag[] = []; const otherMatches: ClientTag[] = []; + const visited = new Set(); if (!forceCreateOption) { for (const tag of tagStore.tagList) { + if (includeSubtags && visited.has(tag)) { + continue; + } if (!filter(tag)) { continue; } const match = tag.isMatch(normalizedQuery); - if (match === 1) { - exactMatches.push(tag); - } else if (match === 2) { - otherMatches.push(tag); - } if (match > 0) { - widest = widest - ? tag.pathCharLength > widest.pathCharLength - ? tag - : widest - : tag; + let matchTags: Iterable; + const targetArray = match === 1 ? exactMatches : otherMatches; + if (includeSubtags) { + matchTags = tag.getImpliedSubTree(visited); + } else { + matchTags = [tag]; + } + for (const t of matchTags) { + widest = widest ? (t.pathCharLength > widest.pathCharLength ? t : widest) : t; + targetArray.push(t); + } } } } else { diff --git a/src/frontend/containers/AppToolbar/Menus.tsx b/src/frontend/containers/AppToolbar/Menus.tsx index dcc042343..b5fef1571 100644 --- a/src/frontend/containers/AppToolbar/Menus.tsx +++ b/src/frontend/containers/AppToolbar/Menus.tsx @@ -66,7 +66,8 @@ const sortMenuData: Array<{ { prop: 'extension', icon: IconSet.FILTER_FILE_TYPE, text: 'File type' }, { prop: 'size', icon: IconSet.FILTER_FILTER_DOWN, text: 'File size' }, { prop: 'dateAdded', icon: IconSet.FILTER_DATE, text: 'Date added' }, - { prop: 'dateModified', icon: IconSet.FILTER_DATE, text: 'Date modified' }, + { prop: 'dateModified', icon: IconSet.FILTER_DATE, text: 'Date modified in app' }, + { prop: 'dateModifiedOS', icon: IconSet.FILTER_DATE, text: 'Date modified' }, { prop: 'dateCreated', icon: IconSet.FILTER_DATE, text: 'Date created' }, { prop: 'random', icon: IconSet.RELOAD_COMPACT, text: 'Random', hideDirection: true }, ]; diff --git a/src/frontend/containers/ContentView/index.tsx b/src/frontend/containers/ContentView/index.tsx index e9d5272b6..d23b83ab4 100644 --- a/src/frontend/containers/ContentView/index.tsx +++ b/src/frontend/containers/ContentView/index.tsx @@ -83,7 +83,9 @@ const Content = observer(() => { const clearFileSelection = useAction((e: React.MouseEvent | React.KeyboardEvent) => { const isLayout = e.currentTarget.firstElementChild?.contains(e.target as Node); - if (!uiStore.isSlideMode && isLayout) { + const targetNode = e.target as Node; + const isInspector = targetNode instanceof Element && targetNode.closest('.inspector'); + if (!uiStore.isSlideMode && !isInspector && isLayout) { uiStore.clearFileSelection(); } }); diff --git a/src/frontend/containers/ContentView/menu-items.tsx b/src/frontend/containers/ContentView/menu-items.tsx index 8246d1a03..4055d7704 100644 --- a/src/frontend/containers/ContentView/menu-items.tsx +++ b/src/frontend/containers/ContentView/menu-items.tsx @@ -216,11 +216,25 @@ export const FileViewerMenuItems = ({ file }: { file: ClientFile }) => { onClick={(e) => handleSearchSimilar( e, - new ClientDateSearchCriteria(undefined, 'dateModified', file.dateModified, 'equals'), + new ClientDateSearchCriteria( + undefined, + 'dateModifiedOS', + file.dateModifiedOS, + 'equals', + ), ) } text="Same Modification Date" /> + + handleSearchSimilar( + e, + new ClientDateSearchCriteria(undefined, 'dateModified', file.dateModified, 'equals'), + ) + } + text="Same Modification Date in App" + /> ); diff --git a/src/frontend/containers/HelpCenter.tsx b/src/frontend/containers/HelpCenter.tsx index dcf37cc1c..b34ee01fb 100644 --- a/src/frontend/containers/HelpCenter.tsx +++ b/src/frontend/containers/HelpCenter.tsx @@ -313,25 +313,6 @@ const PAGE_DATA: () => 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: ( @@ -384,7 +365,7 @@ const PAGE_DATA: () => IPageData[] = () => [

You can set aliases, tag descriptions, implied relationships, and other settings for a tag through the tag's properties editor. To open it, use the contextual menu option - "Edit Tag" or by selecting a tag and pressing the shortcut key "5". + "Edit Tag" or by selecting a tag and pressing the shortcut key "4".

Finally, to remove or edit an entry, right-click it and choose an action from the @@ -403,7 +384,7 @@ const PAGE_DATA: () => IPageData[] = () => [ <>

You can set implied relationships to a tag through the tag's properties editor, {'('} - right-click "Edit Tag" or shortcut key "5"{')'}. When you tag a file, it also inherits + right-click "Edit Tag" or shortcut key "4"{')'}. When you tag a file, it also inherits all its ancestor tags and implied tags (and their implied tags as well) automatically, which are used in searches. For example, if the tag dog implies{' '} mammal, and mammal implies @@ -435,22 +416,45 @@ const PAGE_DATA: () => IPageData[] = () => [ title: 'How to Tag an Image', content: ( <> +

There are several ways to tag your images and manage your collection efficiently:

+
    +
  • + Drag and Drop: Drag a tag from the outliner directly onto an image + or a selection of multiple images. +
  • +
  • + The Tag Editor: Select one or more images, press 3 to + open the Tag Editor, and assign or remove tags from the list. +
  • +
  • + The Inspector Panel: Add tags directly to the list in the sidebar + on the right when viewing images at full size. +
  • +
  • + Tagging Locations: Right-click a location folder and choose{' '} + "Edit Tags" to automatically assign a specific tag to all files contained + within it. +
  • +
  • + Bulk Tag Pasting: Inside the Tag Editor, you can paste raw + unstructured text, comma-separated values, or text with line breaks to quickly + identify and assign tags in bulk. +
  • +
  • + Local Tagging Service: You can connect and trigger an external + tagging tool via a local HTTP interface. To learn how to configure this integration, + check the "Automatic Tagging" section. +
  • +
+

- There are several ways to tag an image. First, you can drag a tag from the outliner - onto an image. This also works on a selection of multiple images. Next, you can select - an image, press 3 to open the tag editor, and assign or remove tags from the list. - This method also allows you to tag multiple images at once. Finally, you can add tags - by adding them to the list in the inspector panel - the sidebar on the right when - viewing images at at full size. -

-

- To remove tags from one or more images, you have to access either the tag editor or - the inspector. In both places you will be able to remove individual tags or clear the + To remove tags from one or more images, you can use either the Tag Editor or the + Inspector. In both places, you will be able to remove individual tags or clear the entire set of tags on the selected image(s).

- When using the tag editor, you can hold ALT + arrow keys to navigate through the - gallery items while keeping focus on the tag editor. + When using the Tag Editor, you can hold ALT + arrow keys to navigate + through the gallery items while keeping focus on the input field.

), @@ -482,6 +486,95 @@ const PAGE_DATA: () => 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. +

+

API Implementation Specifications

+

+ To interface properly with the application, your local server must expose a base URL + (e.g., http://localhost:5000) and implement the following two + POST endpoints: +

+

1. Core Tagging Endpoint (Base URL)

+

+ The application triggers a POST request to the base URL for each + individual file to retrieve its generated tags. +

+
+ Request Body Format: +
{'{ "file": "" }'}
+ Expected Response Format: +
+                {'{'}
+                
+ {' "tags": ['} +
+ {' { "name": "" },'} +
+ {' { "name": "" },'} +
+ {' ... etc.'} +
+ {' ]'} +
+ {'}'} +
+
+

2. Allowed Files Pre-Filter Endpoint

+

+ Before executing requests, the application hits the /allowed-files/{' '} + sub-path via a POST request. This is used to pre-verify which paths from + a selection the server is capable of processing. +

+
+ Request Body Format: +
+                {'{'}
+                
+ {' "files": ['} +
+ {' "",'} +
+ {' "",'} +
+ {' ...'} +
+ {' ]'} +
+ {'}'} +
+ + Expected Response Format: +
+                {'{'}
+                
+ {' "allowed": ['} +
+ {' "",'} +
+ {' ...'} +
+ {' ]'} +
+ {'}'} +
+
+ + ), + }, ], }, { diff --git a/src/frontend/containers/Inspector/index.tsx b/src/frontend/containers/Inspector/index.tsx index ab5852425..a40ddb181 100644 --- a/src/frontend/containers/Inspector/index.tsx +++ b/src/frontend/containers/Inspector/index.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useId } from 'react'; import { observer } from 'mobx-react-lite'; import { useStore } from '../../contexts/StoreContext'; @@ -12,6 +12,7 @@ import { Thumbnail } from '../ContentView/GalleryItem'; const Inspector = observer(() => { const { uiStore, fileStore } = useStore(); + const epSectionHeaderId = useId(); if ( uiStore.firstItemIndex >= fileStore.fileList.length || @@ -19,7 +20,7 @@ const Inspector = observer(() => { (!uiStore.isSlideMode && !uiStore.isOverviewInspectorOpen) ) { return ( -