diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py index 14d8c666aa..0f4967cc63 100644 --- a/invokeai/app/api/routers/style_presets.py +++ b/invokeai/app/api/routers/style_presets.py @@ -1,10 +1,11 @@ +import csv import io import json import traceback from typing import Optional import pydantic -from fastapi import APIRouter, File, Form, HTTPException, Path, UploadFile +from fastapi import APIRouter, File, Form, HTTPException, Path, Response, UploadFile from fastapi.responses import FileResponse from PIL import Image from pydantic import BaseModel, Field @@ -230,6 +231,35 @@ async def get_style_preset_image( raise HTTPException(status_code=404) +@style_presets_router.get( + "/export", + operation_id="export_style_presets", + responses={200: {"content": {"text/csv": {}}, "description": "A CSV file with the requested data."}}, + status_code=200, +) +async def export_style_presets(): + # Create an in-memory stream to store the CSV data + output = io.StringIO() + writer = csv.writer(output) + + # Write the header + writer.writerow(["name", "prompt", "negative_prompt"]) + + style_presets = ApiDependencies.invoker.services.style_preset_records.get_many(type=PresetType.User) + + for preset in style_presets: + writer.writerow([preset.name, preset.preset_data.positive_prompt, preset.preset_data.negative_prompt]) + + csv_data = output.getvalue() + output.close() + + return Response( + content=csv_data, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=prompt_templates.csv"}, + ) + + @style_presets_router.post( "/import", operation_id="import_style_presets", diff --git a/invokeai/app/services/style_preset_records/style_preset_records_base.py b/invokeai/app/services/style_preset_records/style_preset_records_base.py index 282388c7e4..a4dee2fbbd 100644 --- a/invokeai/app/services/style_preset_records/style_preset_records_base.py +++ b/invokeai/app/services/style_preset_records/style_preset_records_base.py @@ -1,6 +1,7 @@ from abc import ABC, abstractmethod from invokeai.app.services.style_preset_records.style_preset_records_common import ( + PresetType, StylePresetChanges, StylePresetRecordDTO, StylePresetWithoutId, @@ -36,6 +37,6 @@ class StylePresetRecordsStorageBase(ABC): pass @abstractmethod - def get_many(self) -> list[StylePresetRecordDTO]: + def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]: """Gets many workflows.""" pass diff --git a/invokeai/app/services/style_preset_records/style_preset_records_common.py b/invokeai/app/services/style_preset_records/style_preset_records_common.py index 34a30d0377..0391b690cc 100644 --- a/invokeai/app/services/style_preset_records/style_preset_records_common.py +++ b/invokeai/app/services/style_preset_records/style_preset_records_common.py @@ -128,9 +128,9 @@ async def parse_presets_from_file(file: UploadFile) -> list[StylePresetWithoutId style_presets.append(style_preset) except pydantic.ValidationError as e: if file.content_type == "text/csv": - msg = "Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt'" + msg = "Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt' and name cannot be blank" else: # file.content_type == "application/json": - msg = "Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt'" + msg = "Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt' and name cannot be blank" raise InvalidPresetImportDataError(msg) from e finally: file.file.close() diff --git a/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py b/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py index 952cf35ba9..657d73b3bd 100644 --- a/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py +++ b/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py @@ -5,6 +5,7 @@ from invokeai.app.services.invoker import Invoker from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase from invokeai.app.services.style_preset_records.style_preset_records_base import StylePresetRecordsStorageBase from invokeai.app.services.style_preset_records.style_preset_records_common import ( + PresetType, StylePresetChanges, StylePresetNotFoundError, StylePresetRecordDTO, @@ -159,19 +160,25 @@ class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase): self._lock.release() return None - def get_many( - self, - ) -> list[StylePresetRecordDTO]: + def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]: try: self._lock.acquire() main_query = """ SELECT * FROM style_presets - ORDER BY LOWER(name) ASC """ - self._cursor.execute(main_query) + if type is not None: + main_query += "WHERE type = ? " + + main_query += "ORDER BY LOWER(name) ASC" + + if type is not None: + self._cursor.execute(main_query, (type,)) + else: + self._cursor.execute(main_query) + rows = self._cursor.fetchall() style_presets = [StylePresetRecordDTO.from_dict(dict(row)) for row in rows] diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index ec0ae472bb..06aebb218f 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1701,14 +1701,21 @@ "deleteImage": "Delete Image", "deleteTemplate": "Delete Template", "deleteTemplate2": "Are you sure you want to delete this template? This cannot be undone.", + "exportPromptTemplates": "Export My Prompt Templates (CSV)", "editTemplate": "Edit Template", + "exportDownloaded": "Export Downloaded", + "exportFailed": "Unable to generate and download CSV", "flatten": "Flatten selected template into current prompt", - "importTemplates": "Import Prompt Templates", - "importTemplatesDesc": "Format must be either a CSV with columns: 'name', 'prompt' or 'positive_prompt', and 'negative_prompt' included, or a JSON file with keys 'name', 'prompt' or 'positive_prompt', and 'negative_prompt", + "importTemplates": "Import Prompt Templates (CSV/JSON)", + "acceptedColumnsKeys": "Accepted columns/keys:", + "nameColumn": "'name'", + "positivePromptColumn": "'prompt' or 'positive_prompt'", + "negativePromptColumn": "'negative_prompt'", "insertPlaceholder": "Insert placeholder", "myTemplates": "My Templates", "name": "Name", "negativePrompt": "Negative Prompt", + "noTemplates": "No templates", "noMatchingTemplates": "No matching templates", "promptTemplatesDesc1": "Prompt templates add text to the prompts you write in the prompt box.", "promptTemplatesDesc2": "Use the placeholder string
{{placeholder}}
to specify where your prompt should be included in the template.", @@ -1719,6 +1726,7 @@ "searchByName": "Search by name", "shared": "Shared", "sharedTemplates": "Shared Templates", + "templateActions": "Template Actions", "templateDeleted": "Prompt template deleted", "toggleViewMode": "Toggle View Mode", "type": "Type", diff --git a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx index 1a23c458cf..229f9ba6d9 100644 --- a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx +++ b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx @@ -47,6 +47,7 @@ export const IAINoContentFallback = memo((props: IAINoImageFallbackProps) => { userSelect: 'none', opacity: 0.7, color: 'base.500', + fontSize: 'md', ...sx, }), [sx] @@ -55,11 +56,7 @@ export const IAINoContentFallback = memo((props: IAINoImageFallbackProps) => { return ( {icon && } - {props.label && ( - - {props.label} - - )} + {props.label && {props.label}} ); }); diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetCreateButton.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetCreateButton.tsx new file mode 100644 index 0000000000..547dd9fffe --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetCreateButton.tsx @@ -0,0 +1,30 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiPlusBold } from 'react-icons/pi'; + +export const StylePresetCreateButton = () => { + const handleClickAddNew = useCallback(() => { + $stylePresetModalState.set({ + prefilledFormData: null, + updatingStylePresetId: null, + isModalOpen: true, + }); + }, []); + + const { t } = useTranslation(); + + return ( + } + tooltip={t('stylePresets.createPromptTemplate')} + aria-label={t('stylePresets.createPromptTemplate')} + onClick={handleClickAddNew} + size="md" + variant="ghost" + w={8} + h={8} + /> + ); +}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetExportButton.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetExportButton.tsx new file mode 100644 index 0000000000..3b1c6caa39 --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetExportButton.tsx @@ -0,0 +1,68 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { IconButton, spinAnimation } from '@invoke-ai/ui-library'; +import { EMPTY_ARRAY } from 'app/store/constants'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDownloadSimpleBold, PiSpinner } from 'react-icons/pi'; +import { useLazyExportStylePresetsQuery, useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; + +const loadingStyles: SystemStyleObject = { + svg: { animation: spinAnimation }, +}; + +export const StylePresetExportButton = () => { + const [exportStylePresets, { isLoading }] = useLazyExportStylePresetsQuery(); + const { t } = useTranslation(); + const { presetCount } = useListStylePresetsQuery(undefined, { + selectFromResult: ({ data }) => { + const userPresets = data?.filter((preset) => preset.type === 'user') ?? EMPTY_ARRAY; + return { + presetCount: userPresets.length, + }; + }, + }); + const handleClickDownloadCsv = useCallback(async () => { + let blob; + try { + const response = await exportStylePresets().unwrap(); + blob = new Blob([response], { type: 'text/csv' }); + } catch (error) { + toast({ + status: 'error', + title: t('stylePresets.exportFailed'), + }); + return; + } + + if (blob) { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'data.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(url); + toast({ + status: 'success', + title: t('stylePresets.exportDownloaded'), + }); + } + }, [exportStylePresets, t]); + + return ( + : } + tooltip={t('stylePresets.exportPromptTemplates')} + aria-label={t('stylePresets.exportPromptTemplates')} + size="md" + variant="link" + w={8} + h={8} + sx={isLoading ? loadingStyles : undefined} + isDisabled={isLoading || presetCount === 0} + /> + ); +}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetForm/StylePresetPromptField.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetForm/StylePresetPromptField.tsx index 44b38931e2..32c8a51661 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetForm/StylePresetPromptField.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetForm/StylePresetPromptField.tsx @@ -39,6 +39,8 @@ export const StylePresetPromptField = (props: Props) => { } else { field.onChange(value + PRESET_PLACEHOLDER); } + + textareaRef.current?.focus(); }, [value, field, textareaRef]); const isPromptPresent = useMemo(() => value?.includes(PRESET_PLACEHOLDER), [value]); diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx deleted file mode 100644 index 9a248e0e0f..0000000000 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import type { SystemStyleObject } from '@invoke-ai/ui-library'; -import { IconButton, spinAnimation, Text } from '@invoke-ai/ui-library'; -import { toast } from 'features/toast/toast'; -import { useCallback } from 'react'; -import { useDropzone } from 'react-dropzone'; -import { useTranslation } from 'react-i18next'; -import { PiSpinner, PiUploadBold } from 'react-icons/pi'; -import { useImportStylePresetsMutation } from 'services/api/endpoints/stylePresets'; - -const loadingStyles: SystemStyleObject = { - svg: { animation: spinAnimation }, -}; - -export const StylePresetImport = () => { - const [importStylePresets, { isLoading }] = useImportStylePresetsMutation(); - const { t } = useTranslation(); - - const onDropAccepted = useCallback( - async (files: File[]) => { - const file = files[0]; - if (!file) { - return; - } - try { - await importStylePresets(file).unwrap(); - toast({ - status: 'success', - title: t('toast.importSuccessful'), - }); - } catch (error) { - toast({ - status: 'error', - title: t('toast.importFailed'), - }); - } - }, - [importStylePresets, t] - ); - - const { getInputProps, getRootProps } = useDropzone({ - accept: { 'text/csv': ['.csv'], 'application/json': ['.json'] }, - onDropAccepted, - noDrag: true, - multiple: false, - }); - - return ( - <> - : } - tooltip={ - <> - {t('stylePresets.importTemplates')} - {t('stylePresets.importTemplatesDesc')} - - } - aria-label={t('stylePresets.importTemplates')} - size="md" - variant="link" - w={8} - h={8} - sx={isLoading ? loadingStyles : undefined} - isDisabled={isLoading} - {...getRootProps()} - /> - - - ); -}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImportButton.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImportButton.tsx new file mode 100644 index 0000000000..54498ba6c3 --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImportButton.tsx @@ -0,0 +1,84 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex, IconButton, ListItem, spinAnimation, Text, UnorderedList } from '@invoke-ai/ui-library'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; +import { PiSpinner, PiUploadSimpleBold } from 'react-icons/pi'; +import { useImportStylePresetsMutation } from 'services/api/endpoints/stylePresets'; + +const loadingStyles: SystemStyleObject = { + svg: { animation: spinAnimation }, +}; + +export const StylePresetImportButton = () => { + const [importStylePresets, { isLoading }] = useImportStylePresetsMutation(); + const { t } = useTranslation(); + + const onDropAccepted = useCallback( + (files: File[]) => { + const file = files[0]; + if (!file) { + return; + } + importStylePresets(file) + .unwrap() + .then(() => { + toast({ + status: 'success', + title: t('toast.importSuccessful'), + }); + }) + .catch((error) => { + toast({ + status: 'error', + title: t('toast.importFailed'), + description: error ? `${error.data.detail}` : undefined, + }); + }); + }, + [importStylePresets, t] + ); + + const { getInputProps, getRootProps } = useDropzone({ + accept: { 'text/csv': ['.csv'], 'application/json': ['.json'] }, + onDropAccepted, + noDrag: true, + multiple: false, + }); + + return ( + <> + : } + tooltip={} + aria-label={t('stylePresets.importTemplates')} + size="md" + variant="link" + w={8} + h={8} + sx={isLoading ? loadingStyles : undefined} + isDisabled={isLoading} + {...getRootProps()} + /> + + + ); +}; + +const TooltipContent = () => { + const { t } = useTranslation(); + return ( + + + {t('stylePresets.importTemplates')} + + {t('stylePresets.acceptedColumnsKeys')} + + {t('stylePresets.nameColumn')} + {t('stylePresets.positivePromptColumn')} + {t('stylePresets.negativePromptColumn')} + + + ); +}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetList.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetList.tsx index cf2a93c265..5c8e9170eb 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetList.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetList.tsx @@ -1,15 +1,16 @@ import { Button, Collapse, Flex, Icon, Text, useDisclosure } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { useTranslation } from 'react-i18next'; import { PiCaretDownBold } from 'react-icons/pi'; import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; import { StylePresetListItem } from './StylePresetListItem'; export const StylePresetList = ({ title, data }: { title: string; data: StylePresetRecordWithImage[] }) => { + const { t } = useTranslation(); const { onToggle, isOpen } = useDisclosure({ defaultIsOpen: true }); - - if (!data.length) { - return <>; - } + const searchTerm = useAppSelector((s) => s.stylePreset.searchTerm); return ( @@ -22,9 +23,16 @@ export const StylePresetList = ({ title, data }: { title: string; data: StylePre - {data.map((preset) => ( - - ))} + {data.length ? ( + data.map((preset) => ) + ) : ( + + )} ); diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx index b024a246fd..51a0a92c66 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx @@ -1,14 +1,13 @@ -import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import { Flex } from '@invoke-ai/ui-library'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; -import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal'; -import { useCallback } from 'react'; +import { StylePresetExportButton } from 'features/stylePresets/components/StylePresetExportButton'; +import { StylePresetImportButton } from 'features/stylePresets/components/StylePresetImportButton'; import { useTranslation } from 'react-i18next'; -import { PiPlusBold } from 'react-icons/pi'; import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; -import { StylePresetImport } from './StylePresetImport'; +import { StylePresetCreateButton } from './StylePresetCreateButton'; import { StylePresetList } from './StylePresetList'; import StylePresetSearch from './StylePresetSearch'; @@ -49,46 +48,19 @@ export const StylePresetMenu = () => { const { t } = useTranslation(); - const handleClickAddNew = useCallback(() => { - $stylePresetModalState.set({ - prefilledFormData: null, - updatingStylePresetId: null, - isModalOpen: true, - }); - }, []); - return ( - - - - } - tooltip={t('stylePresets.createPromptTemplate')} - aria-label={t('stylePresets.createPromptTemplate')} - onClick={handleClickAddNew} - size="md" - variant="link" - w={8} - h={8} - /> - + + + - {data.presets.length === 0 && data.defaultPresets.length === 0 && ( - - {t('stylePresets.noMatchingTemplates')} - - )} - - {allowPrivateStylePresets && ( )} - ); diff --git a/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts b/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts index 7820fbd1f7..2bc945f86e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts @@ -92,6 +92,13 @@ export const stylePresetsApi = api.injectEndpoints({ }), providesTags: ['FetchOnReconnect', { type: 'StylePreset', id: LIST_TAG }], }), + exportStylePresets: build.query({ + query: () => ({ + url: buildStylePresetsUrl('/export'), + responseHandler: (response) => response.text(), + }), + providesTags: ['FetchOnReconnect', { type: 'StylePreset', id: LIST_TAG }], + }), importStylePresets: build.mutation< paths['/api/v1/style_presets/import']['post']['responses']['200']['content']['application/json'], paths['/api/v1/style_presets/import']['post']['requestBody']['content']['multipart/form-data']['file'] @@ -117,5 +124,6 @@ export const { useDeleteStylePresetMutation, useUpdateStylePresetMutation, useListStylePresetsQuery, + useLazyExportStylePresetsQuery, useImportStylePresetsMutation, } = stylePresetsApi; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 52e8f7dd13..f0b6c0198c 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1344,6 +1344,23 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/style_presets/export": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Export Style Presets */ + get: operations["export_style_presets"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/style_presets/import": { parameters: { query?: never; @@ -18109,6 +18126,27 @@ export interface operations { }; }; }; + export_style_presets: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A CSV file with the requested data. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + "text/csv": unknown; + }; + }; + }; + }; import_style_presets: { parameters: { query?: never;