import { Form, Col, Row, Button, FormControl } from "react-bootstrap"; import React, { useEffect, useMemo, useState } from "react"; import * as GQL from "src/core/generated-graphql"; import { Icon } from "../Shared/Icon"; import { LoadingIndicator } from "../Shared/LoadingIndicator"; import { StringListSelect, GallerySelect } from "../Shared/Select"; import * as FormUtils from "src/utils/form"; import ImageUtils from "src/utils/image"; import TextUtils from "src/utils/text"; import { mutateSceneMerge, queryFindScenesByID } from "src/core/StashService"; import { FormattedMessage, useIntl } from "react-intl"; import { useToast } from "src/hooks/Toast"; import { faExchangeAlt, faSignInAlt } from "@fortawesome/free-solid-svg-icons"; import { ScrapeDialog, ScrapeDialogRow, ScrapedImageRow, ScrapedInputGroupRow, ScrapedStringListRow, ScrapedTextAreaRow, } from "../Shared/ScrapeDialog/ScrapeDialog"; import { clone, uniq } from "lodash-es"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ModalComponent } from "../Shared/Modal"; import { IHasStoredID, sortStoredIdObjects } from "src/utils/data"; import { ObjectListScrapeResult, ScrapeResult, ZeroableScrapeResult, hasScrapedValues, } from "../Shared/ScrapeDialog/scrapeResult"; import { ScrapedGroupsRow, ScrapedPerformersRow, ScrapedStudioRow, ScrapedTagsRow, } from "../Shared/ScrapeDialog/ScrapedObjectsRow"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; interface IStashIDsField { values: GQL.StashId[]; } const StashIDsField: React.FC = ({ values }) => { return v.stash_id)} />; }; type MergeOptions = { values: GQL.SceneUpdateInput; includeViewHistory: boolean; includeOHistory: boolean; }; interface ISceneMergeDetailsProps { sources: GQL.SlimSceneDataFragment[]; dest: GQL.SlimSceneDataFragment; onClose: (options?: MergeOptions) => void; } const SceneMergeDetails: React.FC = ({ sources, dest, onClose, }) => { const intl = useIntl(); const [loading, setLoading] = useState(true); const [title, setTitle] = useState>( new ScrapeResult(dest.title) ); const [code, setCode] = useState>( new ScrapeResult(dest.code) ); const [url, setURL] = useState>( new ScrapeResult(dest.urls) ); const [date, setDate] = useState>( new ScrapeResult(dest.date) ); const [rating, setRating] = useState( new ZeroableScrapeResult(dest.rating100) ); // zero values can be treated as missing for these fields const [oCounter, setOCounter] = useState( new ScrapeResult(dest.o_counter) ); const [playCount, setPlayCount] = useState( new ScrapeResult(dest.play_count) ); const [playDuration, setPlayDuration] = useState( new ScrapeResult(dest.play_duration) ); function idToStoredID(o: { id: string; name: string }) { return { stored_id: o.id, name: o.name, }; } function groupToStoredID(o: { group: { id: string; name: string } }) { return { stored_id: o.group.id, name: o.group.name, }; } const [studio, setStudio] = useState>( new ScrapeResult( dest.studio ? idToStoredID(dest.studio) : undefined ) ); function sortIdList(idList?: string[] | null) { if (!idList) { return; } const ret = clone(idList); // sort by id numerically ret.sort((a, b) => { return parseInt(a, 10) - parseInt(b, 10); }); return ret; } function uniqIDStoredIDs(objs: T[]) { return objs.filter((o, i) => { return objs.findIndex((oo) => oo.stored_id === o.stored_id) === i; }); } const [performers, setPerformers] = useState< ObjectListScrapeResult >( new ObjectListScrapeResult( sortStoredIdObjects(dest.performers.map(idToStoredID)) ) ); const [groups, setGroups] = useState< ObjectListScrapeResult >( new ObjectListScrapeResult( sortStoredIdObjects(dest.groups.map(groupToStoredID)) ) ); const [tags, setTags] = useState>( new ObjectListScrapeResult( sortStoredIdObjects(dest.tags.map(idToStoredID)) ) ); const [details, setDetails] = useState>( new ScrapeResult(dest.details) ); const [galleries, setGalleries] = useState>( new ScrapeResult(sortIdList(dest.galleries.map((p) => p.id))) ); const [stashIDs, setStashIDs] = useState(new ScrapeResult([])); const [organized, setOrganized] = useState( new ZeroableScrapeResult(dest.organized) ); const [image, setImage] = useState>( new ScrapeResult(dest.paths.screenshot) ); // calculate the values for everything // uses the first set value for single value fields, and combines all useEffect(() => { async function loadImages() { const src = sources.find((s) => s.paths.screenshot); if (!dest.paths.screenshot || !src) return; setLoading(true); const destData = await ImageUtils.imageToDataURL(dest.paths.screenshot); const srcData = await ImageUtils.imageToDataURL(src.paths.screenshot!); // keep destination image by default const useNewValue = false; setImage(new ScrapeResult(destData, srcData, useNewValue)); setLoading(false); } // append dest to all so that if dest has stash_ids with the same // endpoint, then it will be excluded first const all = sources.concat(dest); setTitle( new ScrapeResult( dest.title, sources.find((s) => s.title)?.title, !dest.title ) ); setCode( new ScrapeResult(dest.code, sources.find((s) => s.code)?.code, !dest.code) ); setURL(new ScrapeResult(dest.urls, uniq(all.map((s) => s.urls).flat()))); setDate( new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date) ); const foundStudio = sources.find((s) => s.studio)?.studio; setStudio( new ScrapeResult( dest.studio ? idToStoredID(dest.studio) : undefined, foundStudio ? { stored_id: foundStudio.id, name: foundStudio.name, } : undefined, !dest.studio ) ); setPerformers( new ObjectListScrapeResult( sortStoredIdObjects(dest.performers.map(idToStoredID)), uniqIDStoredIDs(all.map((s) => s.performers.map(idToStoredID)).flat()) ) ); setTags( new ObjectListScrapeResult( sortStoredIdObjects(dest.tags.map(idToStoredID)), uniqIDStoredIDs(all.map((s) => s.tags.map(idToStoredID)).flat()) ) ); setDetails( new ScrapeResult( dest.details, sources.find((s) => s.details)?.details, !dest.details ) ); setGroups( new ObjectListScrapeResult( sortStoredIdObjects(dest.groups.map(groupToStoredID)), uniqIDStoredIDs(all.map((s) => s.groups.map(groupToStoredID)).flat()) ) ); setGalleries( new ScrapeResult( dest.galleries.map((p) => p.id), uniq(all.map((s) => s.galleries.map((p) => p.id)).flat()) ) ); setRating( new ScrapeResult( dest.rating100, sources.find((s) => s.rating100)?.rating100, !dest.rating100 ) ); setOCounter( new ScrapeResult( dest.o_counter ?? 0, all.map((s) => s.o_counter ?? 0).reduce((pv, cv) => pv + cv, 0) ) ); setPlayCount( new ScrapeResult( dest.play_count ?? 0, all.map((s) => s.play_count ?? 0).reduce((pv, cv) => pv + cv, 0) ) ); setPlayDuration( new ScrapeResult( dest.play_duration ?? 0, all.map((s) => s.play_duration ?? 0).reduce((pv, cv) => pv + cv, 0) ) ); setOrganized( new ScrapeResult( dest.organized ?? false, sources.every((s) => s.organized) ) ); setStashIDs( new ScrapeResult( dest.stash_ids, all .map((s) => s.stash_ids) .flat() .filter((s, index, a) => { // remove entries with duplicate endpoints return index === a.findIndex((ss) => ss.endpoint === s.endpoint); }) ) ); loadImages(); }, [sources, dest]); // ensure this is updated if fields are changed const hasValues = useMemo(() => { return hasScrapedValues([ title, code, url, date, rating, oCounter, galleries, studio, performers, groups, tags, details, organized, stashIDs, image, ]); }, [ title, code, url, date, rating, oCounter, galleries, studio, performers, groups, tags, details, organized, stashIDs, image, ]); function renderScrapeRows() { if (loading) { return (
); } if (!hasValues) { return (
); } const trueString = intl.formatMessage({ id: "true" }); const falseString = intl.formatMessage({ id: "false" }); return ( <> setTitle(value)} /> setCode(value)} /> setURL(value)} /> setDate(value)} /> ( )} renderNewField={() => ( )} onChange={(value) => setRating(value)} /> ( {}} className="bg-secondary text-white border-secondary" /> )} renderNewField={() => ( {}} className="bg-secondary text-white border-secondary" /> )} onChange={(value) => setOCounter(value)} /> ( {}} className="bg-secondary text-white border-secondary" /> )} renderNewField={() => ( {}} className="bg-secondary text-white border-secondary" /> )} onChange={(value) => setPlayCount(value)} /> ( {}} className="bg-secondary text-white border-secondary" /> )} renderNewField={() => ( {}} className="bg-secondary text-white border-secondary" /> )} onChange={(value) => setPlayDuration(value)} /> ( {}} isMulti isDisabled /> )} renderNewField={() => ( {}} isMulti isDisabled /> )} onChange={(value) => setGalleries(value)} /> setStudio(value)} /> setPerformers(value)} ageFromDate={date.useNewValue ? date.newValue : date.originalValue} /> setGroups(value)} /> setTags(value)} /> setDetails(value)} /> ( {}} className="bg-secondary text-white border-secondary" /> )} renderNewField={() => ( {}} className="bg-secondary text-white border-secondary" /> )} onChange={(value) => setOrganized(value)} /> ( )} renderNewField={() => ( )} onChange={(value) => setStashIDs(value)} /> setImage(value)} /> ); } function createValues(): MergeOptions { const all = [dest, ...sources]; // only set the cover image if it's different from the existing cover image const coverImage = image.useNewValue ? image.getNewValue() : undefined; return { values: { id: dest.id, title: title.getNewValue(), code: code.getNewValue(), urls: url.getNewValue(), date: date.getNewValue(), rating100: rating.getNewValue(), o_counter: oCounter.getNewValue(), play_count: playCount.getNewValue(), play_duration: playDuration.getNewValue(), gallery_ids: galleries.getNewValue(), studio_id: studio.getNewValue()?.stored_id, performer_ids: performers.getNewValue()?.map((p) => p.stored_id!), groups: groups.getNewValue()?.map((m) => { // find the equivalent group in the original scenes const found = all .map((s) => s.groups) .flat() .find((mm) => mm.group.id === m.stored_id); return { group_id: m.stored_id!, scene_index: found!.scene_index, }; }), tag_ids: tags.getNewValue()?.map((t) => t.stored_id!), details: details.getNewValue(), organized: organized.getNewValue(), stash_ids: stashIDs.getNewValue(), cover_image: coverImage, }, includeViewHistory: playCount.getNewValue() !== undefined, includeOHistory: oCounter.getNewValue() !== undefined, }; } const dialogTitle = intl.formatMessage({ id: "actions.merge", }); const destinationLabel = !hasValues ? "" : intl.formatMessage({ id: "dialogs.merge.destination" }); const sourceLabel = !hasValues ? "" : intl.formatMessage({ id: "dialogs.merge.source" }); return ( { if (!apply) { onClose(); } else { onClose(createValues()); } }} /> ); }; interface ISceneMergeModalProps { show: boolean; onClose: (mergedID?: string) => void; scenes: { id: string; title: string }[]; } export const SceneMergeModal: React.FC = ({ show, onClose, scenes, }) => { const [sourceScenes, setSourceScenes] = useState([]); const [destScene, setDestScene] = useState([]); const [loadedSources, setLoadedSources] = useState< GQL.SlimSceneDataFragment[] >([]); const [loadedDest, setLoadedDest] = useState(); const [running, setRunning] = useState(false); const [secondStep, setSecondStep] = useState(false); const intl = useIntl(); const Toast = useToast(); const title = intl.formatMessage({ id: "actions.merge", }); useEffect(() => { if (scenes.length > 0) { // set the first scene as the destination, others as source setDestScene([scenes[0]]); if (scenes.length > 1) { setSourceScenes(scenes.slice(1)); } } }, [scenes]); async function loadScenes() { const sceneIDs = sourceScenes.map((s) => parseInt(s.id)); sceneIDs.push(parseInt(destScene[0].id)); const query = await queryFindScenesByID(sceneIDs); const { scenes: loadedScenes } = query.data.findScenes; setLoadedDest(loadedScenes.find((s) => s.id === destScene[0].id)); setLoadedSources(loadedScenes.filter((s) => s.id !== destScene[0].id)); setSecondStep(true); } async function onMerge(options: MergeOptions) { const { values, includeViewHistory, includeOHistory } = options; try { setRunning(true); const result = await mutateSceneMerge( destScene[0].id, sourceScenes.map((s) => s.id), values, includeViewHistory, includeOHistory ); if (result.data?.sceneMerge) { Toast.success(intl.formatMessage({ id: "toast.merged_scenes" })); // refetch the scene await queryFindScenesByID([parseInt(destScene[0].id)]); onClose(destScene[0].id); } onClose(); } catch (e) { Toast.error(e); } finally { setRunning(false); } } function canMerge() { return sourceScenes.length > 0 && destScene.length !== 0; } function switchScenes() { if (sourceScenes.length && destScene.length) { const newDest = sourceScenes[0]; setSourceScenes([...sourceScenes.slice(1), destScene[0]]); setDestScene([newDest]); } } if (secondStep && destScene.length > 0) { return ( { if (values) { onMerge(values); } else { onClose(); } }} /> ); } return ( loadScenes(), }} disabled={!canMerge()} cancel={{ variant: "secondary", onClick: () => onClose(), }} isRunning={running} >
{FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.merge.source" }), labelProps: { column: true, sm: 3, xl: 12, }, })} setSourceScenes(items)} values={sourceScenes} menuPortalTarget={document.body} /> {FormUtils.renderLabel({ title: intl.formatMessage({ id: "dialogs.merge.destination", }), labelProps: { column: true, sm: 3, xl: 12, }, })} setDestScene(items)} values={destScene} menuPortalTarget={document.body} />
); };