From a9014673a0ce9f84556b214a55a1be7cdfd5fae8 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 15 Aug 2024 09:00:11 -0400 Subject: [PATCH 1/7] wip export --- invokeai/app/api/routers/style_presets.py | 30 +++++++++- .../components/StylePresetMenu.tsx | 55 ++++++++++++++++++- .../services/api/endpoints/stylePresets.ts | 11 ++++ .../frontend/web/src/services/api/schema.ts | 38 +++++++++++++ 4 files changed, 131 insertions(+), 3 deletions(-) diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py index 786c522c20..413b9aff08 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 @@ -225,3 +226,30 @@ async def get_style_preset_image( return response except Exception: 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() + + 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=data.csv"} + ) diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx index 021c274048..1218f65d1b 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx @@ -4,12 +4,23 @@ import { useAppSelector } from 'app/store/storeHooks'; import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiPlusBold } from 'react-icons/pi'; +import { PiDownloadBold, PiPlusBold } from 'react-icons/pi'; import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; -import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; +import { useLazyExportStylePresetsQuery, useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; import { StylePresetList } from './StylePresetList'; import StylePresetSearch from './StylePresetSearch'; +import { toast } from '../../toast/toast'; + +const generateCSV = (data: any[]) => { + const header = ['Column1', 'Column2', 'Column3']; + const csvRows = [ + header.join(','), // add header row + ...data.map((row) => row.join(',')), // add data rows + ]; + + return csvRows.join('\n'); +}; export const StylePresetMenu = () => { const searchTerm = useAppSelector((s) => s.stylePreset.searchTerm); @@ -47,6 +58,7 @@ export const StylePresetMenu = () => { }); const { t } = useTranslation(); + const [exportStylePresets, { isLoading }] = useLazyExportStylePresetsQuery(); const handleClickAddNew = useCallback(() => { $stylePresetModalState.set({ @@ -56,10 +68,49 @@ export const StylePresetMenu = () => { }); }, []); + 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: 'Unable to generate and download export', + }); + } + + 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: 'Export downloaded', + }); + }, [exportStylePresets]); + return ( + } + tooltip={t('stylePresets.createPromptTemplate')} + aria-label={t('stylePresets.createPromptTemplate')} + onClick={handleClickDownloadCsv} + size="md" + variant="link" + w={8} + h={8} + isDisabled={isLoading} + /> } tooltip={t('stylePresets.createPromptTemplate')} diff --git a/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts b/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts index 2e1b1a7108..a6dccfa32c 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts @@ -92,6 +92,16 @@ export const stylePresetsApi = api.injectEndpoints({ }), providesTags: ['FetchOnReconnect', { type: 'StylePreset', id: LIST_TAG }], }), + exportStylePresets: build.query< + string, + void + >({ + query: () => ({ + url: buildStylePresetsUrl("/export"), + responseHandler: response => response.text() + }), + providesTags: ['FetchOnReconnect', { type: 'StylePreset', id: LIST_TAG }], + }), }), }); @@ -100,4 +110,5 @@ export const { useDeleteStylePresetMutation, useUpdateStylePresetMutation, useListStylePresetsQuery, + useLazyExportStylePresetsQuery } = stylePresetsApi; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 02c1c88412..e422cca904 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; + }; }; export type webhooks = Record; export type components = { @@ -18083,4 +18100,25 @@ 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; + }; + }; + }; + }; } From 24f298283f6e20f5449c6136f707d5a038f6c0a9 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 15 Aug 2024 12:39:55 -0400 Subject: [PATCH 2/7] clean up, add context menu to import/download templates --- .../style_preset_records_common.py | 4 +- invokeai/frontend/web/public/locales/en.json | 7 +- .../components/StylePresetImport.tsx | 69 --------------- .../components/StylePresetMenu.tsx | 85 +------------------ .../StylePresetExport.tsx | 47 ++++++++++ .../StylePresetImport.tsx | 62 ++++++++++++++ .../StylePresetMenuActions.tsx | 50 +++++++++++ .../services/api/endpoints/stylePresets.ts | 9 +- 8 files changed, 174 insertions(+), 159 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx create mode 100644 invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx create mode 100644 invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetImport.tsx create mode 100644 invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx 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/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index ec0ae472bb..b2959245ea 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1701,10 +1701,14 @@ "deleteImage": "Delete Image", "deleteTemplate": "Delete Template", "deleteTemplate2": "Are you sure you want to delete this template? This cannot be undone.", + "downloadCsv": "Download 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", + "importTemplatesDesc": "Accepted formats: CSV or JSON", + "importTemplatesDesc2": "Accepted columns/keys: 'name', 'prompt' or 'positive_prompt', 'negative_prompt", "insertPlaceholder": "Insert placeholder", "myTemplates": "My Templates", "name": "Name", @@ -1719,6 +1723,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/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/StylePresetMenu.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx index d8023d63cb..06742053af 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx @@ -1,27 +1,13 @@ -import { Flex, IconButton, Text } from '@invoke-ai/ui-library'; +import { Flex, Text } 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 { useTranslation } from 'react-i18next'; -import { PiDownloadBold, PiPlusBold } from 'react-icons/pi'; import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; -import { useLazyExportStylePresetsQuery, useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; +import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; -import { StylePresetImport } from './StylePresetImport'; import { StylePresetList } from './StylePresetList'; +import { StylePresetMenuActions } from './StylePresetMenuActions/StylePresetMenuActions'; import StylePresetSearch from './StylePresetSearch'; -import { toast } from '../../toast/toast'; - -const generateCSV = (data: any[]) => { - const header = ['Column1', 'Column2', 'Column3']; - const csvRows = [ - header.join(','), // add header row - ...data.map((row) => row.join(',')), // add data rows - ]; - - return csvRows.join('\n'); -}; export const StylePresetMenu = () => { const searchTerm = useAppSelector((s) => s.stylePreset.searchTerm); @@ -59,73 +45,12 @@ export const StylePresetMenu = () => { }); const { t } = useTranslation(); - const [exportStylePresets, { isLoading }] = useLazyExportStylePresetsQuery(); - - const handleClickAddNew = useCallback(() => { - $stylePresetModalState.set({ - prefilledFormData: null, - updatingStylePresetId: null, - isModalOpen: true, - }); - }, []); - - 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: 'Unable to generate and download export', - }); - } - - 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: 'Export downloaded', - }); - }, [exportStylePresets]); return ( - - - } - tooltip={t('stylePresets.createPromptTemplate')} - aria-label={t('stylePresets.createPromptTemplate')} - onClick={handleClickDownloadCsv} - size="md" - variant="link" - w={8} - h={8} - isDisabled={isLoading} - /> - - } - 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 && ( @@ -135,11 +60,9 @@ export const StylePresetMenu = () => { )} - {allowPrivateStylePresets && ( )} - ); diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx new file mode 100644 index 0000000000..9e93e78be4 --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx @@ -0,0 +1,47 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDownloadSimpleBold, PiSpinner } from 'react-icons/pi'; +import { useLazyExportStylePresetsQuery } from 'services/api/endpoints/stylePresets'; + +export const StylePresetExport = ({ onClose }: { onClose: () => void }) => { + const [exportStylePresets, { isLoading }] = useLazyExportStylePresetsQuery(); + const { t } = useTranslation(); + + 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'), + }); + } finally { + onClose(); + } + + 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, onClose, t]); + + return ( + : } onClickCapture={handleClickDownloadCsv}> + {t('stylePresets.downloadCsv')} + + ); +}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetImport.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetImport.tsx new file mode 100644 index 0000000000..e6d96da7cb --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetImport.tsx @@ -0,0 +1,62 @@ +import { Flex, MenuItem, 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, PiUploadSimpleBold } from 'react-icons/pi'; +import { useImportStylePresetsMutation } from 'services/api/endpoints/stylePresets'; + +export const StylePresetImport = ({ onClose }: { onClose: () => void }) => { + 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, + }); + }) + .finally(() => { + onClose(); + }); + }, + [importStylePresets, t, onClose] + ); + + const { getInputProps, getRootProps } = useDropzone({ + accept: { 'text/csv': ['.csv'], 'application/json': ['.json'] }, + onDropAccepted, + noDrag: true, + multiple: false, + }); + + return ( + : } alignItems="flex-start" {...getRootProps()}> + + {t('stylePresets.importTemplates')} + + {t('stylePresets.importTemplatesDesc')} + + + {t('stylePresets.importTemplatesDesc2')} + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx new file mode 100644 index 0000000000..1048a009d0 --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx @@ -0,0 +1,50 @@ +import { Flex, IconButton, Menu, MenuButton, MenuList, useDisclosure } from '@invoke-ai/ui-library'; +import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDotsThreeOutlineFill, PiPlusBold } from 'react-icons/pi'; + +import { StylePresetExport } from './StylePresetExport'; +import { StylePresetImport } from './StylePresetImport'; + +export const StylePresetMenuActions = () => { + const { isOpen, onClose, onToggle } = useDisclosure(); + 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} + /> + + } + onClick={onToggle} + /> + + + + + + + ); +}; diff --git a/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts b/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts index 2626e75eac..2bc945f86e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts @@ -92,13 +92,10 @@ export const stylePresetsApi = api.injectEndpoints({ }), providesTags: ['FetchOnReconnect', { type: 'StylePreset', id: LIST_TAG }], }), - exportStylePresets: build.query< - string, - void - >({ + exportStylePresets: build.query({ query: () => ({ - url: buildStylePresetsUrl("/export"), - responseHandler: response => response.text() + url: buildStylePresetsUrl('/export'), + responseHandler: (response) => response.text(), }), providesTags: ['FetchOnReconnect', { type: 'StylePreset', id: LIST_TAG }], }), From 599db7296fd8db25e90b249b45151f308882b18b Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 15 Aug 2024 16:07:32 -0400 Subject: [PATCH 3/7] export only user style presets --- invokeai/app/api/routers/style_presets.py | 2 +- .../style_preset_records_base.py | 3 ++- .../style_preset_records_sqlite.py | 17 ++++++++++++----- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py index 5c1759d73b..aa0296507c 100644 --- a/invokeai/app/api/routers/style_presets.py +++ b/invokeai/app/api/routers/style_presets.py @@ -245,7 +245,7 @@ async def export_style_presets(): # Write the header writer.writerow(["name", "prompt", "negative_prompt"]) - style_presets = ApiDependencies.invoker.services.style_preset_records.get_many() + 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]) 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_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] From 7a3eaa8da9fd0b44852d331f25b38f170bd65768 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 16 Aug 2024 09:51:46 +1000 Subject: [PATCH 4/7] feat(api): save file as `prompt_templates.csv` --- invokeai/app/api/routers/style_presets.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py index aa0296507c..0f4967cc63 100644 --- a/invokeai/app/api/routers/style_presets.py +++ b/invokeai/app/api/routers/style_presets.py @@ -254,7 +254,9 @@ async def export_style_presets(): output.close() return Response( - content=csv_data, media_type="text/csv", headers={"Content-Disposition": "attachment; filename=data.csv"} + content=csv_data, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=prompt_templates.csv"}, ) From 26bfbdec7f4682d4011c810436f21d8a786751ad Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 16 Aug 2024 09:58:19 +1000 Subject: [PATCH 5/7] feat(ui): use buttons instead of menu for preset import/export --- invokeai/frontend/web/public/locales/en.json | 10 +-- .../components/StylePresetCreateButton.tsx | 30 ++++++++ .../components/StylePresetExportButton.tsx | 68 +++++++++++++++++++ ...Import.tsx => StylePresetImportButton.tsx} | 56 ++++++++++----- .../components/StylePresetMenu.tsx | 8 ++- .../StylePresetExport.tsx | 47 ------------- .../StylePresetMenuActions.tsx | 50 -------------- 7 files changed, 149 insertions(+), 120 deletions(-) create mode 100644 invokeai/frontend/web/src/features/stylePresets/components/StylePresetCreateButton.tsx create mode 100644 invokeai/frontend/web/src/features/stylePresets/components/StylePresetExportButton.tsx rename invokeai/frontend/web/src/features/stylePresets/components/{StylePresetMenuActions/StylePresetImport.tsx => StylePresetImportButton.tsx} (50%) delete mode 100644 invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx delete mode 100644 invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index b2959245ea..046a16f4b6 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1701,14 +1701,16 @@ "deleteImage": "Delete Image", "deleteTemplate": "Delete Template", "deleteTemplate2": "Are you sure you want to delete this template? This cannot be undone.", - "downloadCsv": "Download CSV", + "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": "Accepted formats: CSV or JSON", - "importTemplatesDesc2": "Accepted columns/keys: 'name', 'prompt' or 'positive_prompt', '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", 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/StylePresetMenuActions/StylePresetImport.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImportButton.tsx similarity index 50% rename from invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetImport.tsx rename to invokeai/frontend/web/src/features/stylePresets/components/StylePresetImportButton.tsx index e6d96da7cb..54498ba6c3 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetImport.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImportButton.tsx @@ -1,4 +1,5 @@ -import { Flex, MenuItem, Text } from '@invoke-ai/ui-library'; +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'; @@ -6,7 +7,11 @@ import { useTranslation } from 'react-i18next'; import { PiSpinner, PiUploadSimpleBold } from 'react-icons/pi'; import { useImportStylePresetsMutation } from 'services/api/endpoints/stylePresets'; -export const StylePresetImport = ({ onClose }: { onClose: () => void }) => { +const loadingStyles: SystemStyleObject = { + svg: { animation: spinAnimation }, +}; + +export const StylePresetImportButton = () => { const [importStylePresets, { isLoading }] = useImportStylePresetsMutation(); const { t } = useTranslation(); @@ -30,12 +35,9 @@ export const StylePresetImport = ({ onClose }: { onClose: () => void }) => { title: t('toast.importFailed'), description: error ? `${error.data.detail}` : undefined, }); - }) - .finally(() => { - onClose(); }); }, - [importStylePresets, t, onClose] + [importStylePresets, t] ); const { getInputProps, getRootProps } = useDropzone({ @@ -46,17 +48,37 @@ export const StylePresetImport = ({ onClose }: { onClose: () => void }) => { }); return ( - : } alignItems="flex-start" {...getRootProps()}> - - {t('stylePresets.importTemplates')} - - {t('stylePresets.importTemplatesDesc')} - - - {t('stylePresets.importTemplatesDesc2')} - - + <> + : } + 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/StylePresetMenu.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx index 06742053af..9365a0df28 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx @@ -1,12 +1,14 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; +import { StylePresetExportButton } from 'features/stylePresets/components/StylePresetExportButton'; +import { StylePresetImportButton } from 'features/stylePresets/components/StylePresetImportButton'; import { useTranslation } from 'react-i18next'; import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; +import { StylePresetCreateButton } from './StylePresetCreateButton'; import { StylePresetList } from './StylePresetList'; -import { StylePresetMenuActions } from './StylePresetMenuActions/StylePresetMenuActions'; import StylePresetSearch from './StylePresetSearch'; export const StylePresetMenu = () => { @@ -50,7 +52,9 @@ export const StylePresetMenu = () => { - + + + {data.presets.length === 0 && data.defaultPresets.length === 0 && ( diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx deleted file mode 100644 index 9e93e78be4..0000000000 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetExport.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { MenuItem } from '@invoke-ai/ui-library'; -import { toast } from 'features/toast/toast'; -import { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiDownloadSimpleBold, PiSpinner } from 'react-icons/pi'; -import { useLazyExportStylePresetsQuery } from 'services/api/endpoints/stylePresets'; - -export const StylePresetExport = ({ onClose }: { onClose: () => void }) => { - const [exportStylePresets, { isLoading }] = useLazyExportStylePresetsQuery(); - const { t } = useTranslation(); - - 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'), - }); - } finally { - onClose(); - } - - 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, onClose, t]); - - return ( - : } onClickCapture={handleClickDownloadCsv}> - {t('stylePresets.downloadCsv')} - - ); -}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx deleted file mode 100644 index 1048a009d0..0000000000 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuActions/StylePresetMenuActions.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Flex, IconButton, Menu, MenuButton, MenuList, useDisclosure } from '@invoke-ai/ui-library'; -import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal'; -import { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiDotsThreeOutlineFill, PiPlusBold } from 'react-icons/pi'; - -import { StylePresetExport } from './StylePresetExport'; -import { StylePresetImport } from './StylePresetImport'; - -export const StylePresetMenuActions = () => { - const { isOpen, onClose, onToggle } = useDisclosure(); - 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} - /> - - } - onClick={onToggle} - /> - - - - - - - ); -}; From 39c7ec3cd9ffd794f6cd04afa54ebef92e152660 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 16 Aug 2024 10:11:43 +1000 Subject: [PATCH 6/7] feat(ui): per type fallbacks for templates --- invokeai/frontend/web/public/locales/en.json | 1 + .../common/components/IAIImageFallback.tsx | 7 ++---- .../components/StylePresetList.tsx | 22 +++++++++++++------ .../components/StylePresetMenu.tsx | 8 +------ 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 046a16f4b6..06aebb218f 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1715,6 +1715,7 @@ "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.", 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/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 9365a0df28..51a0a92c66 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx @@ -1,4 +1,4 @@ -import { Flex, 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 { StylePresetExportButton } from 'features/stylePresets/components/StylePresetExportButton'; @@ -57,12 +57,6 @@ export const StylePresetMenu = () => {
- {data.presets.length === 0 && data.defaultPresets.length === 0 && ( - - {t('stylePresets.noMatchingTemplates')} - - )} - {allowPrivateStylePresets && ( From 3fb4e3050c218b7d550c35bfc508dce6d027e85c Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 16 Aug 2024 10:14:25 +1000 Subject: [PATCH 7/7] feat(ui): focus in textarea after inserting placeholder --- .../components/StylePresetForm/StylePresetPromptField.tsx | 2 ++ 1 file changed, 2 insertions(+) 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]);