From eaa23240f7daf97d26065cb2fa6baa4c378c7904 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 4 Aug 2021 09:44:51 +1000 Subject: [PATCH] Tagger UI improvements (#1605) * Choose fields to tag * Use check-circle for success icon * Maintain fingerprint results * Show scene details * Maintain whitespace in TruncatedText * Use undefine for img when not setting --- .../src/components/Changelog/versions/v090.md | 2 + ui/v2.5/src/components/Shared/Icon.tsx | 7 +- ui/v2.5/src/components/Shared/SuccessIcon.tsx | 2 +- ui/v2.5/src/components/Shared/styles.scss | 2 + ui/v2.5/src/components/Tagger/Config.tsx | 4 +- .../src/components/Tagger/IncludeButton.tsx | 43 ++++ .../src/components/Tagger/PerformerResult.tsx | 23 +- .../components/Tagger/StashSearchResult.tsx | 193 +++++++++++++-- .../src/components/Tagger/StudioResult.tsx | 23 +- ui/v2.5/src/components/Tagger/TaggerList.tsx | 55 ++++- ui/v2.5/src/components/Tagger/TaggerScene.tsx | 74 +++++- ui/v2.5/src/components/Tagger/constants.ts | 3 +- ui/v2.5/src/components/Tagger/styles.scss | 55 ++++- .../src/components/Tagger/taggerService.ts | 228 ++++++++---------- 14 files changed, 544 insertions(+), 170 deletions(-) create mode 100644 ui/v2.5/src/components/Tagger/IncludeButton.tsx diff --git a/ui/v2.5/src/components/Changelog/versions/v090.md b/ui/v2.5/src/components/Changelog/versions/v090.md index eea9b6ea6..d656a7ce9 100644 --- a/ui/v2.5/src/components/Changelog/versions/v090.md +++ b/ui/v2.5/src/components/Changelog/versions/v090.md @@ -1,7 +1,9 @@ ### ✨ New Features +* Support excluding fields and editing tags when saving from scene tagger view. ([#1605](https://github.com/stashapp/stash/pull/1605)) * Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568)) ### 🎨 Improvements +* Show current scene details in tagger view. ([#1605](https://github.com/stashapp/stash/pull/1605)) * Removed stripes and added background colour to default performer images (old images can be downloaded from the PR link). ([#1609](https://github.com/stashapp/stash/pull/1609)) * Added pt-BR language option. ([#1587](https://github.com/stashapp/stash/pull/1587)) * Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578)) diff --git a/ui/v2.5/src/components/Shared/Icon.tsx b/ui/v2.5/src/components/Shared/Icon.tsx index 584b37447..ab158fb0b 100644 --- a/ui/v2.5/src/components/Shared/Icon.tsx +++ b/ui/v2.5/src/components/Shared/Icon.tsx @@ -2,10 +2,13 @@ import React from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { IconProp, SizeProp, library } from "@fortawesome/fontawesome-svg-core"; import { faStar as fasStar } from "@fortawesome/free-solid-svg-icons"; -import { faStar as farStar } from "@fortawesome/free-regular-svg-icons"; +import { + faCheckCircle as farCheckCircle, + faStar as farStar, +} from "@fortawesome/free-regular-svg-icons"; // need these to use far and fas styles of stars -library.add(fasStar, farStar); +library.add(fasStar, farStar, farCheckCircle); interface IIcon { icon: IconProp; diff --git a/ui/v2.5/src/components/Shared/SuccessIcon.tsx b/ui/v2.5/src/components/Shared/SuccessIcon.tsx index 3a92ba8d3..292dae0ed 100644 --- a/ui/v2.5/src/components/Shared/SuccessIcon.tsx +++ b/ui/v2.5/src/components/Shared/SuccessIcon.tsx @@ -6,7 +6,7 @@ interface ISuccessIconProps { } const SuccessIcon: React.FC = ({ className }) => ( - + ); export default SuccessIcon; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 658744505..99a51bcb8 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -180,9 +180,11 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active { -webkit-box-orient: vertical; display: -webkit-box; overflow: hidden; + white-space: pre-line; &-tooltip .tooltip-inner { max-width: 300px; + white-space: pre-line; } } diff --git a/ui/v2.5/src/components/Tagger/Config.tsx b/ui/v2.5/src/components/Tagger/Config.tsx index b0aa77037..6faa452ed 100644 --- a/ui/v2.5/src/components/Tagger/Config.tsx +++ b/ui/v2.5/src/components/Tagger/Config.tsx @@ -11,7 +11,7 @@ import { FormattedMessage, useIntl } from "react-intl"; import { Icon } from "src/components/Shared"; import { useConfiguration } from "src/core/StashService"; -import { ITaggerConfig, ParseMode } from "./constants"; +import { ITaggerConfig, ParseMode, TagOperation } from "./constants"; interface IConfigProps { show: boolean; @@ -118,7 +118,7 @@ const Config: React.FC = ({ show, config, setConfig }) => { onChange={(e: React.ChangeEvent) => setConfig({ ...config, - tagOperation: e.currentTarget.value, + tagOperation: e.currentTarget.value as TagOperation, }) } disabled={!config.setTags} diff --git a/ui/v2.5/src/components/Tagger/IncludeButton.tsx b/ui/v2.5/src/components/Tagger/IncludeButton.tsx new file mode 100644 index 000000000..4292dfa23 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/IncludeButton.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Button } from "react-bootstrap"; +import { Icon } from "../Shared"; + +interface IIncludeExcludeButton { + exclude: boolean; + disabled?: boolean; + setExclude: (v: boolean) => void; +} + +export const IncludeExcludeButton: React.FC = ({ + exclude, + disabled, + setExclude, +}) => ( + +); + +interface IOptionalField { + exclude: boolean; + disabled?: boolean; + setExclude: (v: boolean) => void; +} + +export const OptionalField: React.FC = ({ + exclude, + setExclude, + children, +}) => ( +
+ + {children} +
+); diff --git a/ui/v2.5/src/components/Tagger/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/PerformerResult.tsx index cabc1444b..0f0726842 100755 --- a/ui/v2.5/src/components/Tagger/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerResult.tsx @@ -3,12 +3,13 @@ import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import cx from "classnames"; -import { SuccessIcon, PerformerSelect } from "src/components/Shared"; +import { PerformerSelect } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; import { ValidTypes } from "src/components/Shared/Select"; import { IStashBoxPerformer, filterPerformer } from "./utils"; import PerformerModal from "./PerformerModal"; +import { OptionalField } from "./IncludeButton"; export type PerformerOperation = | { type: "create"; data: IStashBoxPerformer } @@ -121,12 +122,22 @@ const PerformerResult: React.FC = ({ {performer.name} - - : + + v ? handlePerformerSkip() : setSelectedSource("existing") + } + > +
+ + : + + + {stashData.findPerformers.performers[0].name} + +
+
- - {stashData.findPerformers.performers[0].name} - ); } diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index ebfa55ed2..d370f2bd8 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -1,18 +1,24 @@ -import React, { useState, useReducer } from "react"; +import React, { useState, useReducer, useEffect, useCallback } from "react"; import cx from "classnames"; -import { Button } from "react-bootstrap"; +import { Badge, Button, Col, Form, Row } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { + Icon, LoadingIndicator, SuccessIcon, + TagSelect, TruncatedText, } from "src/components/Shared"; +import { FormUtils } from "src/utils"; +import { uniq } from "lodash"; import PerformerResult, { PerformerOperation } from "./PerformerResult"; import StudioResult, { StudioOperation } from "./StudioResult"; import { IStashBoxScene } from "./utils"; import { useTagScene } from "./taggerService"; +import { TagOperation } from "./constants"; +import { OptionalField } from "./IncludeButton"; const getDurationStatus = ( scene: IStashBoxScene, @@ -95,10 +101,13 @@ interface IStashSearchResultProps { showMales: boolean; setScene: (scene: GQL.SlimSceneDataFragment) => void; setCoverImage: boolean; - tagOperation: string; + tagOperation: TagOperation; setTags: boolean; endpoint: string; queueFingerprintSubmission: (sceneId: string, endpoint: string) => void; + createNewTag: (toCreate: GQL.ScrapedSceneTag) => void; + excludedFields: Record; + setExcludedFields: (v: Record) => void; } interface IPerformerReducerAction { @@ -123,9 +132,30 @@ const StashSearchResult: React.FC = ({ setTags, endpoint, queueFingerprintSubmission, + createNewTag, + excludedFields, + setExcludedFields, }) => { + const getInitialTags = useCallback(() => { + const stashSceneTags = stashScene.tags.map((t) => t.id); + if (!setTags) { + return stashSceneTags; + } + + const newTags = scene.tags.filter((t) => t.id).map((t) => t.id!); + if (tagOperation === "overwrite") { + return newTags; + } + if (tagOperation === "merge") { + return uniq(stashSceneTags.concat(newTags)); + } + + throw new Error("unexpected tagOperation"); + }, [stashScene, tagOperation, scene, setTags]); + const [studio, setStudio] = useState(); const [performers, dispatch] = useReducer(performerReducer, {}); + const [tagIDs, setTagIDs] = useState(getInitialTags()); const [saveState, setSaveState] = useState(""); const [error, setError] = useState<{ message?: string; details?: string }>( {} @@ -133,6 +163,10 @@ const StashSearchResult: React.FC = ({ const intl = useIntl(); + useEffect(() => { + setTagIDs(getInitialTags()); + }, [setTags, tagOperation, getInitialTags]); + const tagScene = useTagScene( { tagOperation, @@ -143,12 +177,18 @@ const StashSearchResult: React.FC = ({ setError ); + function getExcludedFields() { + return Object.keys(excludedFields).filter((f) => excludedFields[f]); + } + async function handleSave() { const updatedScene = await tagScene( stashScene, scene, studio, performers, + tagIDs, + getExcludedFields(), endpoint ); @@ -162,6 +202,12 @@ const StashSearchResult: React.FC = ({ performerID: string ) => dispatch({ id: performerID, data: performerData }); + const setExcludedField = (name: string, value: boolean) => + setExcludedFields({ + ...excludedFields, + [name]: value, + }); + const classname = cx("row mx-0 mt-2 search-result", { "selected-result": isActive, }); @@ -190,38 +236,104 @@ const StashSearchResult: React.FC = ({ ? `${endpointBase}scenes/${scene.stash_id}` : ""; + // constants to get around dot-notation eslint rule + const fields = { + cover_image: "cover_image", + title: "title", + date: "date", + url: "url", + details: "details", + }; + return ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
  • !isActive && setActive()} >
    - - - +
    + setExcludedField(fields.cover_image, v)} + > + + + + +
    -

    {sceneTitle}

    -
    - {scene?.studio?.name} • {scene?.date} -
    -
    - {intl.formatMessage( - { id: "countables.performers" }, - { count: scene?.performers?.length } - )} - : {scene?.performers?.map((p) => p.name).join(", ")} -
    +

    + setExcludedField(fields.title, v)} + > + {sceneTitle} + +

    + + {!isActive && ( + <> +
    + {scene?.studio?.name} • {scene?.date} +
    +
    + {intl.formatMessage( + { id: "countables.performers" }, + { count: scene?.performers?.length } + )} + : {scene?.performers?.map((p) => p.name).join(", ")} +
    + + )} + + {isActive && scene.date && ( +
    + setExcludedField(fields.date, v)} + > + {scene.date} + +
    + )} {getDurationStatus(scene, stashScene.file?.duration)} {getFingerprintStatus(scene, stashScene)}
    + {isActive && ( +
    + {scene.url && ( +
    + setExcludedField(fields.url, v)} + > + + {scene.url} + + +
    + )} + {scene.details && ( +
    + setExcludedField(fields.details, v)} + > + + +
    + )} +
    + )}
    {isActive && (
    @@ -238,6 +350,43 @@ const StashSearchResult: React.FC = ({ endpoint={endpoint} /> ))} +
    +
    + + {FormUtils.renderLabel({ + title: `${intl.formatMessage({ id: "tags" })}:`, + })} + + { + setTagIDs(items.map((i) => i.id)); + }} + ids={tagIDs} + /> + + +
    + {setTags && + scene.tags + .filter((t) => !t.id) + .map((t) => ( + { + createNewTag(t); + }} + > + {t.name} + + + ))} +
    {error.message && ( diff --git a/ui/v2.5/src/components/Tagger/StudioResult.tsx b/ui/v2.5/src/components/Tagger/StudioResult.tsx index 9c8fbaf79..69690e853 100755 --- a/ui/v2.5/src/components/Tagger/StudioResult.tsx +++ b/ui/v2.5/src/components/Tagger/StudioResult.tsx @@ -3,10 +3,11 @@ import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; import cx from "classnames"; -import { SuccessIcon, Modal, StudioSelect } from "src/components/Shared"; +import { Modal, StudioSelect } from "src/components/Shared"; import * as GQL from "src/core/generated-graphql"; import { ValidTypes } from "src/components/Shared/Select"; import { IStashBoxStudio } from "./utils"; +import { OptionalField } from "./IncludeButton"; export type StudioOperation = | { type: "create"; data: IStashBoxStudio } @@ -103,12 +104,22 @@ const StudioResult: React.FC = ({ studio, setStudio }) => { :{studio?.name}
    - - Matched: + + v ? handleStudioSkip() : setSelectedSource("existing") + } + > +
    + + : + + + {stashIDData.findStudios.studios[0].name} + +
    +
    - - {stashIDData.findStudios.studios[0].name} -
    ); } diff --git a/ui/v2.5/src/components/Tagger/TaggerList.tsx b/ui/v2.5/src/components/Tagger/TaggerList.tsx index 0a885df5c..7860c6809 100644 --- a/ui/v2.5/src/components/Tagger/TaggerList.tsx +++ b/ui/v2.5/src/components/Tagger/TaggerList.tsx @@ -4,9 +4,10 @@ import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import { LoadingIndicator } from "src/components/Shared"; -import { stashBoxSceneBatchQuery } from "src/core/StashService"; +import { stashBoxSceneBatchQuery, useTagCreate } from "src/core/StashService"; import { SceneQueue } from "src/models/sceneQueue"; +import { useToast } from "src/hooks"; import { uniqBy } from "lodash"; import { ITaggerConfig } from "./constants"; import { selectScenes, IStashBoxScene } from "./utils"; @@ -76,6 +77,9 @@ export const TaggerList: React.FC = ({ fingerprintQueue, }) => { const intl = useIntl(); + const Toast = useToast(); + const [createTag] = useTagCreate(); + const [fingerprintError, setFingerprintError] = useState(""); const [loading, setLoading] = useState(false); const inputForm = useRef(null); @@ -206,6 +210,53 @@ export const TaggerList: React.FC = ({ setFingerprintError(""); }; + async function createNewTag(toCreate: GQL.ScrapedSceneTag) { + const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" }; + try { + const result = await createTag({ + variables: { + input: tagInput, + }, + }); + + const tagID = result.data?.tagCreate?.id; + + const newSearchResults = { ...searchResults }; + + // add the id to the existing search results + Object.keys(newSearchResults).forEach((k) => { + const searchResult = searchResults[k]; + newSearchResults[k] = searchResult.map((r) => { + return { + ...r, + tags: r.tags.map((t) => { + if (t.name === toCreate.name) { + return { + ...t, + id: tagID, + }; + } + + return t; + }), + }; + }); + }); + + setSearchResults(newSearchResults); + + Toast.success({ + content: ( + + Created tag: {toCreate.name} + + ), + }); + } catch (e) { + Toast.error(e); + } + } + const canFingerprintSearch = () => scenes.some( (s) => @@ -267,6 +318,7 @@ export const TaggerList: React.FC = ({ doSceneQuery={(queryString) => doSceneQuery(scene.id, queryString)} tagScene={handleTaggedScene} searchResult={searchResult} + createNewTag={createNewTag} /> ); }); @@ -274,6 +326,7 @@ export const TaggerList: React.FC = ({ return (
    + {/* TODO - sources select goes here */} {fingerprintError}
    {(getFingerprintCount() > 0 || hideUnmatched) && ( diff --git a/ui/v2.5/src/components/Tagger/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/TaggerScene.tsx index f06eb8ae0..d1f745780 100644 --- a/ui/v2.5/src/components/Tagger/TaggerScene.tsx +++ b/ui/v2.5/src/components/Tagger/TaggerScene.tsx @@ -1,11 +1,12 @@ import React, { useRef, useState } from "react"; -import { Button, Form, InputGroup } from "react-bootstrap"; +import { Button, Collapse, Form, InputGroup } from "react-bootstrap"; import { Link } from "react-router-dom"; import { FormattedMessage } from "react-intl"; import { ScenePreview } from "src/components/Scenes/SceneCard"; import * as GQL from "src/core/generated-graphql"; -import { TruncatedText } from "src/components/Shared"; +import { Icon, TagLink, TruncatedText } from "src/components/Shared"; +import { sortPerformers } from "src/core/performers"; import StashSearchResult from "./StashSearchResult"; import { ITaggerConfig } from "./constants"; import { @@ -15,6 +16,68 @@ import { prepareQueryString, } from "./utils"; +interface ITaggerSceneDetails { + scene: GQL.SlimSceneDataFragment; +} + +const TaggerSceneDetails: React.FC = ({ scene }) => { + const [open, setOpen] = useState(false); + const sorted = sortPerformers(scene.performers); + + return ( +
    + +
    +
    +

    {scene.title}

    +
    + {scene.studio?.name} + {scene.studio?.name && scene.date && ` • `} + {scene.date} +
    + +
    +
    +
    + {sorted.map((performer) => ( +
    + + {performer.name + + +
    + ))} +
    +
    + {scene.tags.map((tag) => ( + + ))} +
    +
    +
    +
    + +
    + ); +}; + export interface ISearchResult { results?: IStashBoxScene[]; error?: string; @@ -32,6 +95,7 @@ export interface ITaggerScene { tagScene: (scene: Partial) => void; endpoint: string; queueFingerprintSubmission: (sceneId: string, endpoint: string) => void; + createNewTag: (toCreate: GQL.ScrapedSceneTag) => void; } export const TaggerScene: React.FC = ({ @@ -46,8 +110,10 @@ export const TaggerScene: React.FC = ({ tagScene, endpoint, queueFingerprintSubmission, + createNewTag, }) => { const [selectedResult, setSelectedResult] = useState(0); + const [excluded, setExcluded] = useState>({}); const queryString = useRef(""); @@ -193,6 +259,9 @@ export const TaggerScene: React.FC = ({ setScene={tagScene} endpoint={endpoint} queueFingerprintSubmission={queueFingerprintSubmission} + createNewTag={createNewTag} + excludedFields={excluded} + setExcludedFields={(v) => setExcluded(v)} /> ) )} @@ -226,6 +295,7 @@ export const TaggerScene: React.FC = ({ {renderMainContent()}
    {renderSubContent()}
    +
    {renderSearchResult()} diff --git a/ui/v2.5/src/components/Tagger/constants.ts b/ui/v2.5/src/components/Tagger/constants.ts index afe1f12d1..5b78060c8 100644 --- a/ui/v2.5/src/components/Tagger/constants.ts +++ b/ui/v2.5/src/components/Tagger/constants.ts @@ -24,13 +24,14 @@ export const initialConfig: ITaggerConfig = { }; export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata"; +export type TagOperation = "merge" | "overwrite"; export interface ITaggerConfig { blacklist: string[]; showMales: boolean; mode: ParseMode; setCoverImage: boolean; setTags: boolean; - tagOperation: string; + tagOperation: TagOperation; selectedEndpoint?: string; fingerprintQueue: Record; excludedPerformerFields?: string[]; diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index f36d00af3..ed38e7d74 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -26,6 +26,13 @@ background-color: #495b68; border-radius: 3px; padding: 1rem; + + .scene-details { + align-items: center; + display: flex; + flex-direction: column; + width: 100%; + } } .search-result { @@ -58,12 +65,11 @@ max-width: 14rem; min-width: 168px; object-fit: contain; - padding: 0 1rem; + padding-right: 1rem; } .scene-metadata { margin-right: 1rem; - width: calc(100% - 17rem); } .select-existing { @@ -204,3 +210,48 @@ width: 12px; } } + +.include-exclude-button { + display: inline-block; + margin-right: 0.38em; + padding: 0.2em; +} + +li:not(.active) { + .include-exclude-button { + // visibility: hidden; + display: none; + } + + .scene-image { + padding-left: 1rem; + } +} + +.optional-field { + align-items: center; + display: flex; + flex-direction: row; +} + +li.active .optional-field.excluded, +li.active .optional-field.excluded .scene-link { + color: #bfccd6; + text-decoration: line-through; + + img { + opacity: 0.5; + } +} + +li.active .scene-image-container { + margin-left: 1rem; +} + +.scene-details { + margin-top: 0.5rem; + + > .row { + width: 100%; + } +} diff --git a/ui/v2.5/src/components/Tagger/taggerService.ts b/ui/v2.5/src/components/Tagger/taggerService.ts index 59f22dce7..3f41f8bde 100644 --- a/ui/v2.5/src/components/Tagger/taggerService.ts +++ b/ui/v2.5/src/components/Tagger/taggerService.ts @@ -1,10 +1,8 @@ import * as GQL from "src/core/generated-graphql"; import { blobToBase64 } from "base64-blob"; -import { uniq } from "lodash"; import { useCreatePerformer, useCreateStudio, - useCreateTag, useUpdatePerformerStashID, useUpdateStudioStashID, } from "./queries"; @@ -25,7 +23,6 @@ export function useTagScene( ) { const createStudio = useCreateStudio(); const createPerformer = useCreatePerformer(); - const createTag = useCreateTag(); const updatePerformerStashID = useUpdatePerformerStashID(); const updateStudioStashID = useUpdateStudioStashID(); const [updateScene] = GQL.useSceneUpdateMutation({ @@ -41,67 +38,76 @@ export function useTagScene( }, }); - const { data: allTags } = GQL.useAllTagsForFilterQuery(); - const handleSave = async ( stashScene: GQL.SlimSceneDataFragment, scene: IStashBoxScene, studio: StudioOperation | undefined, performers: IPerformerOperations, + tagIDs: string[], + excludedFields: string[], endpoint: string ) => { + function resolveField(field: string, stashField: T, remoteField: T) { + if (excludedFields.includes(field)) { + return stashField; + } + + return remoteField; + } + setError({}); let performerIDs = []; let studioID = null; - if (!studio) return; + if (studio) { + if (studio.type === "create") { + setSaveState("Creating studio"); + const newStudio = { + name: studio.data.name, + stash_ids: [ + { + endpoint, + stash_id: scene.studio.stash_id, + }, + ], + url: studio.data.url, + }; + const studioCreateResult = await createStudio( + newStudio, + scene.studio.stash_id + ); - if (studio.type === "create") { - setSaveState("Creating studio"); - const newStudio = { - name: studio.data.name, - stash_ids: [ - { - endpoint, - stash_id: scene.studio.stash_id, - }, - ], - url: studio.data.url, - }; - const studioCreateResult = await createStudio( - newStudio, - scene.studio.stash_id - ); - - if (!studioCreateResult?.data?.studioCreate) { - setError({ - message: `Failed to save studio "${newStudio.name}"`, - details: studioCreateResult?.errors?.[0].message, - }); - return setSaveState(""); + if (!studioCreateResult?.data?.studioCreate) { + setError({ + message: `Failed to save studio "${newStudio.name}"`, + details: studioCreateResult?.errors?.[0].message, + }); + return setSaveState(""); + } + studioID = studioCreateResult.data.studioCreate.id; + } else if (studio.type === "update") { + setSaveState("Saving studio stashID"); + const res = await updateStudioStashID(studio.data, [ + ...studio.data.stash_ids, + { stash_id: scene.studio.stash_id, endpoint }, + ]); + if (!res?.data?.studioUpdate) { + setError({ + message: `Failed to save stashID to studio "${studio.data.name}"`, + details: res?.errors?.[0].message, + }); + return setSaveState(""); + } + studioID = res.data.studioUpdate.id; + } else if (studio.type === "existing") { + studioID = studio.data.id; + } else if (studio.type === "skip") { + studioID = stashScene.studio?.id; } - studioID = studioCreateResult.data.studioCreate.id; - } else if (studio.type === "update") { - setSaveState("Saving studio stashID"); - const res = await updateStudioStashID(studio.data, [ - ...studio.data.stash_ids, - { stash_id: scene.studio.stash_id, endpoint }, - ]); - if (!res?.data?.studioUpdate) { - setError({ - message: `Failed to save stashID to studio "${studio.data.name}"`, - details: res?.errors?.[0].message, - }); - return setSaveState(""); - } - studioID = res.data.studioUpdate.id; - } else if (studio.type === "existing") { - studioID = studio.data.id; - } else if (studio.type === "skip") { - studioID = stashScene.studio?.id; } setSaveState("Saving performers"); + let failed = false; performerIDs = await Promise.all( Object.keys(performers).map(async (stashID) => { const performer = performers[stashID]; @@ -157,6 +163,7 @@ export function useTagScene( message: `Failed to save performer "${performerInput.name}"`, details: res?.errors?.[0].message, }); + failed = true; return null; } performerID = res.data?.performerCreate.id; @@ -174,86 +181,57 @@ export function useTagScene( }) ); - if (!performerIDs.some((id) => !id)) { - setSaveState("Updating scene"); - const imgurl = scene.images[0]; - let imgData = null; - if (imgurl && options.setCoverImage) { - const img = await fetch(imgurl, { - mode: "cors", - cache: "no-store", - }); - if (img.status === 200) { - const blob = await img.blob(); - // Sanity check on image size since bad images will fail - if (blob.size > 10000) imgData = await blobToBase64(blob); - } - } - - let updatedTags = stashScene?.tags?.map((t) => t.id) ?? []; - if (options.setTags) { - const newTagIDs = options.tagOperation === "merge" ? updatedTags : []; - const tags = scene.tags ?? []; - if (tags.length > 0) { - const tagDict: Record = (allTags?.allTags ?? []) - .filter((t) => t.name) - .reduce( - (dict, t) => ({ ...dict, [t.name.toLowerCase()]: t.id }), - {} - ); - const newTags: string[] = []; - tags.forEach((tag) => { - if (tagDict[tag.name.toLowerCase()]) - newTagIDs.push(tagDict[tag.name.toLowerCase()]); - else newTags.push(tag.name); - }); - - const createdTags = await Promise.all( - newTags.map((tag) => createTag(tag)) - ); - createdTags.forEach((createdTag) => { - if (createdTag?.data?.tagCreate?.id) - newTagIDs.push(createdTag.data.tagCreate.id); - }); - } - updatedTags = uniq(newTagIDs); - } - - const performer_ids = performerIDs.filter( - (id) => id !== "Skip" - ) as string[]; - - const sceneUpdateResult = await updateScene({ - variables: { - input: { - id: stashScene.id ?? "", - title: scene.title, - details: scene.details, - date: scene.date, - performer_ids: - performer_ids.length === 0 - ? stashScene.performers.map((p) => p.id) - : performer_ids, - studio_id: studioID, - cover_image: imgData, - url: scene.url, - tag_ids: updatedTags, - stash_ids: [ - ...(stashScene?.stash_ids ?? []), - { - endpoint, - stash_id: scene.stash_id, - }, - ], - }, - }, - }); - - setSaveState(""); - return sceneUpdateResult?.data?.sceneUpdate; + if (failed) { + return setSaveState(""); } + setSaveState("Updating scene"); + const imgurl = scene.images[0]; + let imgData; + if (imgurl && options.setCoverImage) { + const img = await fetch(imgurl, { + mode: "cors", + cache: "no-store", + }); + if (img.status === 200) { + const blob = await img.blob(); + // Sanity check on image size since bad images will fail + if (blob.size > 10000) imgData = await blobToBase64(blob); + } + } + + const performer_ids = performerIDs.filter( + (id) => id !== "Skip" + ) as string[]; + + const sceneUpdateResult = await updateScene({ + variables: { + input: { + id: stashScene.id ?? "", + title: resolveField("title", stashScene.title, scene.title), + details: resolveField("details", stashScene.details, scene.details), + date: resolveField("date", stashScene.date, scene.date), + performer_ids: + performer_ids.length === 0 + ? stashScene.performers.map((p) => p.id) + : performer_ids, + studio_id: studioID, + cover_image: resolveField("cover_image", undefined, imgData), + url: resolveField("url", stashScene.url, scene.url), + tag_ids: tagIDs, + stash_ids: [ + ...(stashScene?.stash_ids ?? []), + { + endpoint, + stash_id: scene.stash_id, + }, + ], + }, + }, + }); + setSaveState(""); + return sceneUpdateResult?.data?.sceneUpdate; }; return handleSave;