diff --git a/frontend/src/App.scss b/frontend/src/App.scss index 2f92b7972..54dcd048c 100644 --- a/frontend/src/App.scss +++ b/frontend/src/App.scss @@ -18,6 +18,7 @@ @import "./components/performerCard/styles"; @import "./components/performerSelect/styles"; @import "./components/sceneCard/styles"; +@import "./components/sceneSelect/styles"; @import "./components/searchField/styles"; @import "./components/studioSelect/styles"; @import "./components/tagFilter/styles"; diff --git a/frontend/src/components/sceneSelect/SceneSelect.tsx b/frontend/src/components/sceneSelect/SceneSelect.tsx new file mode 100644 index 000000000..94870792e --- /dev/null +++ b/frontend/src/components/sceneSelect/SceneSelect.tsx @@ -0,0 +1,139 @@ +import { useApolloClient } from "@apollo/client/react"; +import debounce from "p-debounce"; +import { type FC, useState } from "react"; +import type { OnChangeValue } from "react-select"; +import Async from "react-select/async"; +import { SearchInput, TagLink } from "src/components/fragments"; +import type { + SearchScenesQuery, + SearchScenesQueryVariables, +} from "src/graphql"; +import SearchScenesGQL from "src/graphql/queries/SearchScenes.gql"; +import { sceneHref } from "src/utils/route"; + +type Scene = NonNullable; + +export type SceneSlim = { + id: string; + title?: string | null; + release_date?: string | null; + deleted: boolean; +}; + +interface SceneSelectProps { + scenes?: SceneSlim[]; + onChange: (scenes: SceneSlim[]) => void; + message?: string; + excludeScenes?: string[]; + inputId?: string; +} + +interface SearchResult { + value: Scene; + label: string; + sublabel: string; +} + +const CLASSNAME = "SceneSelect"; +const CLASSNAME_LIST = `${CLASSNAME}-list`; +const CLASSNAME_SELECT = `${CLASSNAME}-select`; +const CLASSNAME_CONTAINER = `${CLASSNAME}-container`; + +const SceneSelect: FC = ({ + scenes: initialScenes = [], + onChange, + message = "Add scene:", + excludeScenes = [], + inputId, +}) => { + const client = useApolloClient(); + const [scenes, setScenes] = useState(initialScenes); + const excluded = [...excludeScenes, ...scenes.map((s) => s.id)]; + + const handleChange = (result: OnChangeValue) => { + if (result?.value) { + const { id, title, release_date, deleted } = result.value; + const newScenes = [...scenes, { id, title, release_date, deleted }]; + setScenes(newScenes); + onChange(newScenes); + } + }; + + const removeScene = (id: string) => { + const newScenes = scenes.filter((scene) => scene.id !== id); + setScenes(newScenes); + onChange(newScenes); + }; + + const sceneList = [...scenes] + .sort((a, b) => (a.title ?? "").localeCompare(b.title ?? "")) + .map((scene) => ( + removeScene(scene.id)} + disabled + /> + )); + + const handleSearch = async (term: string) => { + const { data } = await client.query< + SearchScenesQuery, + SearchScenesQueryVariables + >({ + query: SearchScenesGQL, + variables: { term, per_page: 25 }, + }); + + const results = (data?.searchScenes.scenes ?? []) + .filter((scene) => !excluded.includes(scene.id) && !scene.deleted) + .map((scene) => ({ + value: scene, + label: scene.title ?? "", + sublabel: [scene.release_date, scene.studio?.name] + .filter(Boolean) + .join(" \u2022 "), + })); + + return results.length > 0 ? [{ label: "Scenes", options: results }] : []; + }; + + const debouncedLoadOptions = debounce(handleSearch, 400); + + const formatOptionLabel = ({ label, sublabel, value }: SearchResult) => ( +
+
+ {value.deleted ? {label} : label} +
+
{sublabel}
+
+ ); + + return ( +
+
{sceneList}
+
+ {message} + + inputValue === "" ? null : `No scenes found for "${inputValue}"` + } + controlShouldRenderValue={false} + formatOptionLabel={formatOptionLabel} + components={{ Input: SearchInput }} + /> +
+
+ ); +}; + +export default SceneSelect; diff --git a/frontend/src/components/sceneSelect/index.ts b/frontend/src/components/sceneSelect/index.ts new file mode 100644 index 000000000..a7c42f140 --- /dev/null +++ b/frontend/src/components/sceneSelect/index.ts @@ -0,0 +1,4 @@ +import SceneSelect from "./SceneSelect"; + +export type { SceneSlim } from "./SceneSelect"; +export default SceneSelect; diff --git a/frontend/src/components/sceneSelect/styles.scss b/frontend/src/components/sceneSelect/styles.scss new file mode 100644 index 000000000..52ae2f87b --- /dev/null +++ b/frontend/src/components/sceneSelect/styles.scss @@ -0,0 +1,27 @@ +.SceneSelect { + margin-top: 0.5rem; + + &-list { + margin-bottom: 1rem; + } + + &-container { + display: flex; + } + + &-select { + display: inline-block; + margin-left: auto; + width: 25rem; + + &-value { + font-size: 14px; + font-weight: 500; + } + + &-subvalue { + font-size: 12px; + color: $text-muted; + } + } +} diff --git a/frontend/src/constants/route.ts b/frontend/src/constants/route.ts index 9336d98b5..920017580 100644 --- a/frontend/src/constants/route.ts +++ b/frontend/src/constants/route.ts @@ -17,6 +17,7 @@ export const ROUTE_PERFORMERS = "/performers"; export const ROUTE_SCENE = "/scenes/:id"; export const ROUTE_SCENE_ADD = "/scenes/add"; export const ROUTE_SCENE_EDIT = "/scenes/:id/edit"; +export const ROUTE_SCENE_MERGE = "/scenes/:id/merge"; export const ROUTE_SCENE_DELETE = "/scenes/:id/delete"; export const ROUTE_SCENE_FINGERPRINT_CLUSTERS = "/scenes/:id/fingerprints"; export const ROUTE_SCENES = "/scenes"; diff --git a/frontend/src/pages/scenes/Scene.tsx b/frontend/src/pages/scenes/Scene.tsx index 7f9067a7b..16e51829a 100644 --- a/frontend/src/pages/scenes/Scene.tsx +++ b/frontend/src/pages/scenes/Scene.tsx @@ -9,7 +9,11 @@ import { } from "src/components/fragments"; import Image from "src/components/image"; import { EditList, URLList } from "src/components/list"; -import { ROUTE_SCENE_DELETE, ROUTE_SCENE_EDIT } from "src/constants/route"; +import { + ROUTE_SCENE_DELETE, + ROUTE_SCENE_EDIT, + ROUTE_SCENE_MERGE, +} from "src/constants/route"; import { type SceneFragment as Scene, TargetTypeEnum, @@ -87,6 +91,12 @@ const SceneComponent: FC = ({ scene }) => { + + + = ({ scene }) => { + const navigate = useNavigate(); + const [submissionError, setSubmissionError] = useState(""); + const [mergeSources, setMergeSources] = useState([]); + + const { + sources: loadedSources, + ready: sourcesReady, + error: sourcesError, + } = useEntities(mergeSources, "findScene", SceneFragmentDoc); + + const [insertSceneEdit, { loading: saving }] = useSceneEdit({ + onCompleted: (data) => { + if (submissionError) setSubmissionError(""); + if (data.sceneEdit.id) navigate(editHref(data.sceneEdit)); + }, + onError: (error) => setSubmissionError(error.message), + }); + + const doUpdate = (insertData: SceneEditDetailsInput, editNote: string) => { + insertSceneEdit({ + variables: { + sceneData: { + edit: { + id: scene.id, + operation: OperationEnum.MERGE, + merge_source_ids: mergeSources.map((s) => s.id), + comment: editNote, + }, + details: insertData, + }, + }, + }); + }; + + const { initial, conflicts } = useMemo( + () => buildSceneMerge(scene, loadedSources), + [scene, loadedSources], + ); + + return ( +
+

+ Merge scenes into {scene.title} +

+
+ + + + setMergeSources(scenes)} + message="Select scenes to merge:" + excludeScenes={[scene.id, ...mergeSources.map((s) => s.id)]} + inputId="scene-merge-source-select" + /> + + +

+ Merging scenes deletes the source scenes and redirects them to the + target scene. Previously generated content referencing the source + scenes will resolve to the target scene. +

+

+ This operation is not easily reversible and attention should be paid + that all scenes are truly the same. +

+ +
+
+
+ Modify {scene.title} +
+ + {submissionError && ( +
Error: {submissionError}
+ )} + {sourcesError ? ( +
+ Failed to load scene details: {sourcesError.message} +
+ ) : sourcesReady ? ( + + ) : ( + + )} +
+
+ ); +}; + +export default SceneMerge; diff --git a/frontend/src/pages/scenes/index.tsx b/frontend/src/pages/scenes/index.tsx index c0b1c30f9..4d550dc0a 100644 --- a/frontend/src/pages/scenes/index.tsx +++ b/frontend/src/pages/scenes/index.tsx @@ -9,6 +9,7 @@ import Scene from "./Scene"; import SceneAdd from "./SceneAdd"; import SceneDelete from "./SceneDelete"; import SceneEdit from "./SceneEdit"; +import SceneMerge from "./SceneMerge"; import Scenes from "./Scenes"; const SceneLoader: FC = () => { @@ -24,6 +25,15 @@ const SceneLoader: FC = () => { return ( + + + <SceneMerge scene={scene} /> + </> + } + /> <Route path="/delete" element={ diff --git a/frontend/src/pages/scenes/sceneForm/SceneForm.tsx b/frontend/src/pages/scenes/sceneForm/SceneForm.tsx index 110d841ee..2e5ae1232 100644 --- a/frontend/src/pages/scenes/sceneForm/SceneForm.tsx +++ b/frontend/src/pages/scenes/sceneForm/SceneForm.tsx @@ -14,6 +14,7 @@ import { renderSceneDetails } from "src/components/editCard/ModifyEdit"; import EditImages from "src/components/editImages"; import { EditNote, NavButtons, SubmitButtons } from "src/components/form"; import { GenderIcon, Icon } from "src/components/fragments"; +import MergeConflicts from "src/components/mergeConflicts"; import SearchField, { type PerformerResult, SearchType, @@ -33,6 +34,7 @@ import { useBeforeUnload } from "src/hooks/useBeforeUnload"; import { formatDuration, parseDuration, performerHref } from "src/utils"; import DiffScene from "./diff"; import ExistingSceneAlert from "./ExistingSceneAlert"; +import type { SceneMergeConflict } from "./merge"; import { type SceneFormData, SceneSchema } from "./schema"; import type { InitialScene } from "./types"; @@ -42,6 +44,7 @@ const CLASS_NAME_PERFORMER_CHANGE = `${CLASS_NAME}-performer-change`; interface SceneProps { scene?: Scene | null; initial?: InitialScene; + conflicts?: SceneMergeConflict[]; callback: (updateData: SceneEditDetailsInput, editNote: string) => void; saving: boolean; isCreate?: boolean; @@ -55,6 +58,7 @@ interface SceneProps { const SceneForm: FC<SceneProps> = ({ scene, initial, + conflicts, callback, saving, isCreate = false, @@ -66,6 +70,7 @@ const SceneForm: FC<SceneProps> = ({ control, handleSubmit, watch, + setValue, formState: { errors }, } = useForm({ resolver: yupResolver(SceneSchema), @@ -322,6 +327,23 @@ const SceneForm: FC<SceneProps> = ({ return ( <Form className={CLASS_NAME} onSubmit={handleSubmit(onSubmit)}> + {conflicts && conflicts.length > 0 && ( + <Row> + <Col xs={9}> + <MergeConflicts + conflicts={conflicts} + values={fieldData} + onSelect={(field, value) => + // RHF cannot infer the value type from a dynamic field name. + setValue(field, value as never, { + shouldDirty: true, + shouldValidate: true, + }) + } + /> + </Col> + </Row> + )} {isCreate && ( <Row> <Col xs={9}> diff --git a/frontend/src/pages/scenes/sceneForm/__tests__/merge.test.ts b/frontend/src/pages/scenes/sceneForm/__tests__/merge.test.ts new file mode 100644 index 000000000..8afad2b41 --- /dev/null +++ b/frontend/src/pages/scenes/sceneForm/__tests__/merge.test.ts @@ -0,0 +1,123 @@ +import type { SceneFragment } from "src/graphql/types"; +import { describe, expect, it } from "vitest"; +import { buildSceneMerge } from "../merge"; + +const scene = ( + id: string, + overrides: Record<string, unknown> = {}, +): SceneFragment => + ({ + id, + release_date: null, + production_date: null, + title: `title-${id}`, + deleted: false, + details: null, + director: null, + code: null, + duration: null, + urls: [], + images: [], + studio: null, + performers: [], + tags: [], + ...overrides, + }) as unknown as SceneFragment; + +describe("buildSceneMerge", () => { + it("fills empty target fields from the first source with a value", () => { + const target = scene("target"); + const source = scene("source", { + details: "source details", + director: "source director", + release_date: "2020-01-01", + }); + + const { initial } = buildSceneMerge(target, [source]); + + expect(initial.details).toBe("source details"); + expect(initial.director).toBe("source director"); + expect(initial.date).toBe("2020-01-01"); + }); + + it("prefers the target value when it is set", () => { + const target = scene("target", { details: "target details" }); + const source = scene("source", { details: "source details" }); + + const { initial } = buildSceneMerge(target, [source]); + + expect(initial.details).toBe("target details"); + }); + + it("combines multi-value fields, deduplicating by id", () => { + const target = scene("target", { + tags: [{ id: "t1", name: "A", aliases: [] }], + images: [{ id: "i1", url: "u1", width: 1, height: 1 }], + }); + const source = scene("source", { + tags: [ + { id: "t1", name: "A", aliases: [] }, + { id: "t2", name: "B", aliases: [] }, + ], + images: [{ id: "i2", url: "u2", width: 1, height: 1 }], + }); + + const { initial } = buildSceneMerge(target, [source]); + + expect(initial.tags?.map((tag) => tag.id)).toEqual(["t1", "t2"]); + expect(initial.images?.map((image) => image.id)).toEqual(["i1", "i2"]); + }); + + it("reports a conflict when single-value fields differ", () => { + const target = scene("target", { director: "one" }); + const source = scene("source", { director: "two" }); + + const { conflicts } = buildSceneMerge(target, [source]); + + const conflict = conflicts.find((c) => c.field === "director"); + expect(conflict?.options.map((o) => o.value)).toEqual(["one", "two"]); + expect(conflict?.options[0].sources).toEqual(["title-target"]); + }); + + it("reports a studio conflict keyed by id", () => { + const target = scene("target", { studio: { id: "s1", name: "Studio A" } }); + const source = scene("source", { studio: { id: "s2", name: "Studio B" } }); + + const { conflicts } = buildSceneMerge(target, [source]); + + const conflict = conflicts.find((c) => c.field === "studio"); + expect(conflict?.options.map((o) => o.display)).toEqual([ + "Studio A", + "Studio B", + ]); + expect(conflict?.options[1].value).toEqual({ id: "s2", name: "Studio B" }); + expect(conflict?.currentKey({ id: "s2", name: "Studio B" })).toBe("s2"); + }); + + it("reports a duration conflict with formatted values", () => { + const target = scene("target", { duration: 60 }); + const source = scene("source", { duration: 120 }); + + const { conflicts } = buildSceneMerge(target, [source]); + + const conflict = conflicts.find((c) => c.field === "duration"); + expect(conflict?.options.map((o) => o.value)).toEqual(["01:00", "02:00"]); + }); + + it("does not report conflicts when values match or are unset", () => { + const target = scene("target", { + title: "same", + director: "same", + duration: 60, + }); + const source = scene("source", { + title: "same", + director: "same", + duration: 60, + }); + + const { conflicts } = buildSceneMerge(target, [source]); + + expect(conflicts).toHaveLength(0); + }); +}); diff --git a/frontend/src/pages/scenes/sceneForm/merge.ts b/frontend/src/pages/scenes/sceneForm/merge.ts new file mode 100644 index 000000000..d5154ead5 --- /dev/null +++ b/frontend/src/pages/scenes/sceneForm/merge.ts @@ -0,0 +1,168 @@ +import { uniq, uniqBy } from "lodash-es"; +import type { MergeConflict } from "src/components/mergeConflicts"; +import type { SceneFragment as Scene } from "src/graphql"; +import { formatDuration } from "src/utils"; +import type { SceneFormData } from "./schema"; +import type { InitialScene } from "./types"; + +export type SceneMergeConflict = MergeConflict<keyof SceneFormData>; + +const sceneLabel = (scene: Scene) => + [scene.title, scene.release_date, scene.studio?.name] + .filter(Boolean) + .join(" ") || scene.id; + +type Scalar = string | number; + +interface ScalarField { + field: keyof SceneFormData; + initialKey: keyof InitialScene; + label: string; + get: (scene: Scene) => Scalar | null | undefined; +} + +const SCALAR_FIELDS: ScalarField[] = [ + { field: "title", initialKey: "title", label: "Title", get: (s) => s.title }, + { + field: "details", + initialKey: "details", + label: "Details", + get: (s) => s.details, + }, + { + field: "date", + initialKey: "date", + label: "Date", + get: (s) => s.release_date, + }, + { + field: "production_date", + initialKey: "production_date", + label: "Production Date", + get: (s) => s.production_date, + }, + { + field: "director", + initialKey: "director", + label: "Director", + get: (s) => s.director, + }, + { + field: "code", + initialKey: "code", + label: "Studio Code", + get: (s) => s.code, + }, +]; + +const isSet = (value: Scalar | null | undefined): value is Scalar => + value !== null && value !== undefined && value !== ""; + +// Builds the seed values and detected conflicts for merging the sources into +// the target. Empty target fields are filled from the first source that has a +// value; multi-value fields are combined; single-value fields that differ +// across scenes are returned as conflicts for the user to resolve. +export const buildSceneMerge = ( + target: Scene, + sources: Scene[], +): { initial: InitialScene; conflicts: SceneMergeConflict[] } => { + const all = [target, ...sources]; + const initial: InitialScene = {}; + const conflicts: SceneMergeConflict[] = []; + + for (const def of SCALAR_FIELDS) { + const values = all.map(def.get); + + const merged = values.find(isSet); + if (merged !== undefined) { + (initial as Record<string, unknown>)[def.initialKey] = merged; + } + + const distinct = uniq(values.filter(isSet)); + if (distinct.length > 1) { + conflicts.push({ + field: def.field, + label: def.label, + currentKey: (value) => (value == null ? "" : String(value)), + options: distinct.map((value) => ({ + key: String(value), + value, + display: String(value), + sources: all + .filter((scene) => def.get(scene) === value) + .map(sceneLabel), + })), + }); + } + } + + const durations = all + .map((scene) => scene.duration) + .filter((d): d is number => !!d); + if (durations.length > 0) initial.duration = durations[0]; + + const distinctDurations = uniq(durations); + if (distinctDurations.length > 1) { + conflicts.push({ + field: "duration", + label: "Duration", + currentKey: (value) => (value == null ? "" : String(value)), + options: distinctDurations.map((duration) => ({ + key: formatDuration(duration), + value: formatDuration(duration), + display: formatDuration(duration), + sources: all + .filter((scene) => scene.duration === duration) + .map(sceneLabel), + })), + }); + } + + const studios = all + .map((scene) => scene.studio) + .filter((s): s is NonNullable<Scene["studio"]> => s != null) + .map(({ id, name }) => ({ id, name })); + if (studios.length > 0) initial.studio = studios[0]; + + const distinctStudioIds = uniq(studios.map((s) => s.id)); + if (distinctStudioIds.length > 1) { + conflicts.push({ + field: "studio", + label: "Studio", + currentKey: (value) => (value as { id?: string } | null)?.id ?? "", + options: distinctStudioIds.map((id) => { + const studio = studios.find((s) => s.id === id) as { + id: string; + name: string; + }; + return { + key: id, + value: { id: studio.id, name: studio.name }, + display: studio.name, + sources: all + .filter((scene) => scene.studio?.id === id) + .map(sceneLabel), + }; + }), + }); + } + + initial.urls = uniqBy( + all.flatMap((scene) => scene.urls), + (url) => `${url.url}-${url.site.id}`, + ); + initial.images = uniqBy( + all.flatMap((scene) => scene.images), + (image) => image.id, + ); + initial.tags = uniqBy( + all.flatMap((scene) => scene.tags), + (tag) => tag.id, + ); + initial.performers = uniqBy( + all.flatMap((scene) => scene.performers), + (performance) => performance.performer.id, + ); + + return { initial, conflicts }; +};