From 723446842f215645c4a468e5e120803752b19038 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 2 Aug 2021 10:32:23 +1000 Subject: [PATCH] Split up Tagger component (#1534) --- .../src/components/Tagger/PerformerResult.tsx | 4 + .../components/Tagger/StashSearchResult.tsx | 260 +------ ui/v2.5/src/components/Tagger/Tagger.tsx | 684 ++---------------- ui/v2.5/src/components/Tagger/TaggerList.tsx | 329 +++++++++ ui/v2.5/src/components/Tagger/TaggerScene.tsx | 233 ++++++ ui/v2.5/src/components/Tagger/styles.scss | 2 +- .../src/components/Tagger/taggerService.ts | 260 +++++++ ui/v2.5/src/components/Tagger/utils.ts | 111 +++ 8 files changed, 1026 insertions(+), 857 deletions(-) create mode 100644 ui/v2.5/src/components/Tagger/TaggerList.tsx create mode 100644 ui/v2.5/src/components/Tagger/TaggerScene.tsx create mode 100644 ui/v2.5/src/components/Tagger/taggerService.ts diff --git a/ui/v2.5/src/components/Tagger/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/PerformerResult.tsx index d9125f974..cabc1444b 100755 --- a/ui/v2.5/src/components/Tagger/PerformerResult.tsx +++ b/ui/v2.5/src/components/Tagger/PerformerResult.tsx @@ -16,6 +16,10 @@ export type PerformerOperation = | { type: "existing"; data: GQL.PerformerDataFragment } | { type: "skip" }; +export interface IPerformerOperations { + [x: string]: PerformerOperation; +} + interface IPerformerResultProps { performer: IStashBoxPerformer; setPerformer: (data: PerformerOperation) => void; diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx index bebb39907..ebfa55ed2 100755 --- a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -2,8 +2,6 @@ import React, { useState, useReducer } from "react"; import cx from "classnames"; import { Button } from "react-bootstrap"; import { FormattedMessage, useIntl } from "react-intl"; -import { uniq } from "lodash"; -import { blobToBase64 } from "base64-blob"; import * as GQL from "src/core/generated-graphql"; import { @@ -14,13 +12,7 @@ import { import PerformerResult, { PerformerOperation } from "./PerformerResult"; import StudioResult, { StudioOperation } from "./StudioResult"; import { IStashBoxScene } from "./utils"; -import { - useCreateTag, - useCreatePerformer, - useCreateStudio, - useUpdatePerformerStashID, - useUpdateStudioStashID, -} from "./queries"; +import { useTagScene } from "./taggerService"; const getDurationStatus = ( scene: IStashBoxScene, @@ -140,240 +132,36 @@ const StashSearchResult: React.FC = ({ ); const intl = useIntl(); - const createStudio = useCreateStudio(); - const createPerformer = useCreatePerformer(); - const createTag = useCreateTag(); - const updatePerformerStashID = useUpdatePerformerStashID(); - const updateStudioStashID = useUpdateStudioStashID(); - const [updateScene] = GQL.useSceneUpdateMutation({ - onError: (e) => { - const message = - e.message === "invalid JPEG format: short Huffman data" - ? "Failed to save scene due to corrupted cover image" - : "Failed to save scene"; - setError({ - message, - details: e.message, - }); - }, - }); - const { data: allTags } = GQL.useAllTagsForFilterQuery(); + const tagScene = useTagScene( + { + tagOperation, + setCoverImage, + setTags, + }, + setSaveState, + setError + ); + + async function handleSave() { + const updatedScene = await tagScene( + stashScene, + scene, + studio, + performers, + endpoint + ); + + if (updatedScene) setScene(updatedScene); + + queueFingerprintSubmission(stashScene.id, endpoint); + } const setPerformer = ( performerData: PerformerOperation, performerID: string ) => dispatch({ id: performerID, data: performerData }); - const handleSave = async () => { - setError({}); - let performerIDs = []; - let studioID = null; - - if (!studio) return; - - 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(""); - } - 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"); - performerIDs = await Promise.all( - Object.keys(performers).map(async (stashID) => { - const performer = performers[stashID]; - if (performer.type === "skip") return "Skip"; - - let performerID = performer.data.id; - - if (performer.type === "create") { - const imgurl = performer.data.images[0]; - let imgData = null; - if (imgurl) { - const img = await fetch(imgurl, { - mode: "cors", - cache: "no-store", - }); - if (img.status === 200) { - const blob = await img.blob(); - imgData = await blobToBase64(blob); - } - } - - const performerInput = { - name: performer.data.name, - gender: performer.data.gender, - country: performer.data.country, - height: performer.data.height, - ethnicity: performer.data.ethnicity, - birthdate: performer.data.birthdate, - eye_color: performer.data.eye_color, - fake_tits: performer.data.fake_tits, - measurements: performer.data.measurements, - career_length: performer.data.career_length, - tattoos: performer.data.tattoos, - piercings: performer.data.piercings, - twitter: performer.data.twitter, - instagram: performer.data.instagram, - image: imgData, - stash_ids: [ - { - endpoint, - stash_id: stashID, - }, - ], - details: performer.data.details, - death_date: performer.data.death_date, - hair_color: performer.data.hair_color, - weight: Number(performer.data.weight), - }; - - const res = await createPerformer(performerInput, stashID); - if (!res?.data?.performerCreate) { - setError({ - message: `Failed to save performer "${performerInput.name}"`, - details: res?.errors?.[0].message, - }); - return null; - } - performerID = res.data?.performerCreate.id; - } - - if (performer.type === "update") { - const stashIDs = performer.data.stash_ids; - await updatePerformerStashID(performer.data.id, [ - ...stashIDs, - { stash_id: stashID, endpoint }, - ]); - } - - return performerID; - }) - ); - - if (!performerIDs.some((id) => !id)) { - setSaveState("Updating scene"); - const imgurl = scene.images[0]; - let imgData = null; - if (imgurl && 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 (setTags) { - const newTagIDs = 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, - }, - ], - }, - }, - }); - - if (sceneUpdateResult?.data?.sceneUpdate) - setScene(sceneUpdateResult.data.sceneUpdate); - - queueFingerprintSubmission(stashScene.id, endpoint); - } - - setSaveState(""); - }; - const classname = cx("row mx-0 mt-2 search-result", { "selected-result": isActive, }); diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx index 03ab99a5a..eaeb91656 100755 --- a/ui/v2.5/src/components/Tagger/Tagger.tsx +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -1,621 +1,18 @@ -import React, { useEffect, useRef, useState } from "react"; -import { Button, Card, Form, InputGroup } from "react-bootstrap"; -import { Link } from "react-router-dom"; -import { FormattedMessage, useIntl } from "react-intl"; +import React, { useState } from "react"; +import { Button } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; import { HashLink } from "react-router-hash-link"; -import { uniqBy } from "lodash"; -import { ScenePreview } from "src/components/Scenes/SceneCard"; import { useLocalForage } from "src/hooks"; import * as GQL from "src/core/generated-graphql"; -import { LoadingIndicator, TruncatedText } from "src/components/Shared"; -import { - stashBoxSceneQuery, - stashBoxSceneBatchQuery, - useConfiguration, -} from "src/core/StashService"; +import { LoadingIndicator } from "src/components/Shared"; +import { stashBoxSceneQuery, useConfiguration } from "src/core/StashService"; import { Manual } from "src/components/Help/Manual"; import { SceneQueue } from "src/models/sceneQueue"; -import StashSearchResult from "./StashSearchResult"; import Config from "./Config"; -import { - LOCAL_FORAGE_KEY, - ITaggerConfig, - ParseMode, - initialConfig, -} from "./constants"; -import { - parsePath, - selectScenes, - IStashBoxScene, - sortScenesByDuration, -} from "./utils"; - -const months = [ - "jan", - "feb", - "mar", - "apr", - "may", - "jun", - "jul", - "aug", - "sep", - "oct", - "nov", - "dec", -]; - -const ddmmyyRegex = /\.(\d\d)\.(\d\d)\.(\d\d)\./; -const yyyymmddRegex = /(\d{4})[-.](\d{2})[-.](\d{2})/; -const mmddyyRegex = /(\d{2})[-.](\d{2})[-.](\d{4})/; -const ddMMyyRegex = new RegExp( - `(\\d{1,2}).(${months.join("|")})\\.?.(\\d{4})`, - "i" -); -const MMddyyRegex = new RegExp( - `(${months.join("|")})\\.?.(\\d{1,2}),?.(\\d{4})`, - "i" -); -const parseDate = (input: string): string => { - let output = input; - const ddmmyy = output.match(ddmmyyRegex); - if (ddmmyy) { - output = output.replace( - ddmmyy[0], - ` 20${ddmmyy[1]}-${ddmmyy[2]}-${ddmmyy[3]} ` - ); - } - const mmddyy = output.match(mmddyyRegex); - if (mmddyy) { - output = output.replace( - mmddyy[0], - ` ${mmddyy[1]}-${mmddyy[2]}-${mmddyy[3]} ` - ); - } - const ddMMyy = output.match(ddMMyyRegex); - if (ddMMyy) { - const month = (months.indexOf(ddMMyy[2].toLowerCase()) + 1) - .toString() - .padStart(2, "0"); - output = output.replace( - ddMMyy[0], - ` ${ddMMyy[3]}-${month}-${ddMMyy[1].padStart(2, "0")} ` - ); - } - const MMddyy = output.match(MMddyyRegex); - if (MMddyy) { - const month = (months.indexOf(MMddyy[1].toLowerCase()) + 1) - .toString() - .padStart(2, "0"); - output = output.replace( - MMddyy[0], - ` ${MMddyy[3]}-${month}-${MMddyy[2].padStart(2, "0")} ` - ); - } - - const yyyymmdd = output.search(yyyymmddRegex); - if (yyyymmdd !== -1) - return ( - output.slice(0, yyyymmdd).replace(/-/g, " ") + - output.slice(yyyymmdd, yyyymmdd + 10).replace(/\./g, "-") + - output.slice(yyyymmdd + 10).replace(/-/g, " ") - ); - return output.replace(/-/g, " "); -}; - -function prepareQueryString( - scene: Partial, - paths: string[], - filename: string, - mode: ParseMode, - blacklist: string[] -) { - if ((mode === "auto" && scene.date && scene.studio) || mode === "metadata") { - let str = [ - scene.date, - scene.studio?.name ?? "", - (scene?.performers ?? []).map((p) => p.name).join(" "), - scene?.title ? scene.title.replace(/[^a-zA-Z0-9 ]+/g, "") : "", - ] - .filter((s) => s !== "") - .join(" "); - blacklist.forEach((b) => { - str = str.replace(new RegExp(b, "gi"), " "); - }); - return str; - } - let s = ""; - - if (mode === "auto" || mode === "filename") { - s = filename; - } else if (mode === "path") { - s = [...paths, filename].join(" "); - } else { - s = paths[paths.length - 1]; - } - blacklist.forEach((b) => { - s = s.replace(new RegExp(b, "gi"), " "); - }); - s = parseDate(s); - return s.replace(/\./g, " "); -} - -interface ITaggerListProps { - scenes: GQL.SlimSceneDataFragment[]; - queue?: SceneQueue; - selectedEndpoint: { endpoint: string; index: number }; - config: ITaggerConfig; - queueFingerprintSubmission: (sceneId: string, endpoint: string) => void; - clearSubmissionQueue: (endpoint: string) => void; -} - -// Caches fingerprint lookups between page renders -let fingerprintCache: Record = {}; - -const TaggerList: React.FC = ({ - scenes, - queue, - selectedEndpoint, - config, - queueFingerprintSubmission, - clearSubmissionQueue, -}) => { - const intl = useIntl(); - const [fingerprintError, setFingerprintError] = useState(""); - const [loading, setLoading] = useState(false); - const queryString = useRef>({}); - const inputForm = useRef(null); - - const [searchResults, setSearchResults] = useState< - Record - >({}); - const [searchErrors, setSearchErrors] = useState< - Record - >({}); - const [selectedResult, setSelectedResult] = useState< - Record - >(); - const [selectedFingerprintResult, setSelectedFingerprintResult] = useState< - Record - >(); - const [taggedScenes, setTaggedScenes] = useState< - Record> - >({}); - const [loadingFingerprints, setLoadingFingerprints] = useState(false); - const [fingerprints, setFingerprints] = useState< - Record - >(fingerprintCache); - const [hideUnmatched, setHideUnmatched] = useState(false); - const fingerprintQueue = - config.fingerprintQueue[selectedEndpoint.endpoint] ?? []; - - useEffect(() => { - inputForm?.current?.reset(); - }, [config.mode, config.blacklist]); - - function clearSceneSearchResult(sceneID: string) { - // remove sceneID results from the results object - const { [sceneID]: _removedResult, ...newSearchResults } = searchResults; - const { [sceneID]: _removedError, ...newSearchErrors } = searchErrors; - setSearchResults(newSearchResults); - setSearchErrors(newSearchErrors); - } - - const doBoxSearch = (sceneID: string, searchVal: string) => { - clearSceneSearchResult(sceneID); - - stashBoxSceneQuery(searchVal, selectedEndpoint.index) - .then((queryData) => { - const s = selectScenes(queryData.data?.queryStashBoxScene); - setSearchResults({ - ...searchResults, - [sceneID]: s, - }); - setSearchErrors({ - ...searchErrors, - [sceneID]: undefined, - }); - setLoading(false); - }) - .catch(() => { - setLoading(false); - // Destructure to remove existing result - const { [sceneID]: unassign, ...results } = searchResults; - setSearchResults(results); - setSearchErrors({ - ...searchErrors, - [sceneID]: "Network Error", - }); - }); - - setLoading(true); - }; - - const [ - submitFingerPrints, - { loading: submittingFingerprints }, - ] = GQL.useSubmitStashBoxFingerprintsMutation({ - onCompleted: (result) => { - setFingerprintError(""); - if (result.submitStashBoxFingerprints) - clearSubmissionQueue(selectedEndpoint.endpoint); - }, - onError: () => { - setFingerprintError("Network Error"); - }, - }); - - const handleFingerprintSubmission = () => { - submitFingerPrints({ - variables: { - input: { - stash_box_index: selectedEndpoint.index, - scene_ids: fingerprintQueue, - }, - }, - }); - }; - - const handleTaggedScene = (scene: Partial) => { - setTaggedScenes({ - ...taggedScenes, - [scene.id as string]: scene, - }); - }; - - const handleFingerprintSearch = async () => { - setLoadingFingerprints(true); - const newFingerprints = { ...fingerprints }; - - const sceneIDs = scenes - .filter((s) => s.stash_ids.length === 0) - .map((s) => s.id); - - const results = await stashBoxSceneBatchQuery( - sceneIDs, - selectedEndpoint.index - ).catch(() => { - setLoadingFingerprints(false); - setFingerprintError("Network Error"); - }); - - if (!results) return; - - // clear search errors - setSearchErrors({}); - - selectScenes(results.data?.queryStashBoxScene).forEach((scene) => { - scene.fingerprints?.forEach((f) => { - newFingerprints[f.hash] = newFingerprints[f.hash] - ? [...newFingerprints[f.hash], scene] - : [scene]; - }); - }); - - // Null any ids that are still undefined since it means they weren't found - sceneIDs.forEach((id) => { - newFingerprints[id] = newFingerprints[id] ?? null; - }); - - setFingerprints(newFingerprints); - fingerprintCache = newFingerprints; - setLoadingFingerprints(false); - setFingerprintError(""); - }; - - const canFingerprintSearch = () => - scenes.some( - (s) => s.stash_ids.length === 0 && fingerprints[s.id] === undefined - ); - - const getFingerprintCount = () => { - return scenes.filter( - (s) => - s.stash_ids.length === 0 && - ((s.checksum && fingerprints[s.checksum]) || - (s.oshash && fingerprints[s.oshash]) || - (s.phash && fingerprints[s.phash])) - ).length; - }; - - const getFingerprintCountMessage = () => { - const count = getFingerprintCount(); - return intl.formatMessage( - { id: "component_tagger.results.fp_found" }, - { fpCount: count } - ); - }; - - const toggleHideUnmatchedScenes = () => { - setHideUnmatched(!hideUnmatched); - }; - - function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) { - return queue - ? queue.makeLink(scene.id, { sceneIndex: index }) - : `/scenes/${scene.id}`; - } - - const renderScenes = () => - scenes.map((scene, index) => { - const sceneLink = generateSceneLink(scene, index); - const { paths, file, ext } = parsePath(scene.path); - const originalDir = scene.path.slice( - 0, - scene.path.length - file.length - ext.length - ); - const defaultQueryString = prepareQueryString( - scene, - paths, - file, - config.mode, - config.blacklist - ); - - // Get all scenes matching one of the fingerprints, and return array of unique scenes - const fingerprintMatches = uniqBy( - [ - ...(fingerprints[scene.checksum ?? ""] ?? []), - ...(fingerprints[scene.oshash ?? ""] ?? []), - ...(fingerprints[scene.phash ?? ""] ?? []), - ].flat(), - (f) => f.stash_id - ); - - const isTagged = taggedScenes[scene.id]; - const hasStashIDs = scene.stash_ids.length > 0; - const width = scene.file.width ? scene.file.width : 0; - const height = scene.file.height ? scene.file.height : 0; - const isPortrait = height > width; - - let mainContent; - if (!isTagged && hasStashIDs) { - mainContent = ( -
-
- -
-
- ); - } else if (!isTagged && !hasStashIDs) { - mainContent = ( - - - - - - - ) => { - queryString.current[scene.id] = e.currentTarget.value; - }} - onKeyPress={(e: React.KeyboardEvent) => - e.key === "Enter" && - doBoxSearch( - scene.id, - queryString.current[scene.id] || defaultQueryString - ) - } - /> - - - - - ); - } else if (isTagged) { - mainContent = ( -
-
- -
-
- - {taggedScenes[scene.id].title} - -
-
- ); - } - - let subContent; - if (scene.stash_ids.length > 0) { - const stashLinks = scene.stash_ids.map((stashID) => { - const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; - const link = base ? ( - - {stashID.stash_id} - - ) : ( -
{stashID.stash_id}
- ); - - return link; - }); - subContent = <>{stashLinks}; - } else if (searchErrors[scene.id]) { - subContent = ( -
- {searchErrors[scene.id]} -
- ); - } else if (searchResults[scene.id]?.length === 0) { - subContent = ( -
- -
- ); - } - - let searchResult; - if (fingerprintMatches.length > 0 && !isTagged && !hasStashIDs) { - searchResult = sortScenesByDuration( - fingerprintMatches, - scene.file.duration ?? 0 - ).map((match, i) => ( - - setSelectedFingerprintResult({ - ...selectedFingerprintResult, - [scene.id]: i, - }) - } - setScene={handleTaggedScene} - scene={match} - setCoverImage={config.setCoverImage} - setTags={config.setTags} - tagOperation={config.tagOperation} - endpoint={selectedEndpoint.endpoint} - queueFingerprintSubmission={queueFingerprintSubmission} - key={match.stash_id} - /> - )); - } else if ( - searchResults[scene.id]?.length > 0 && - !isTagged && - fingerprintMatches.length === 0 - ) { - searchResult = ( -
    - {sortScenesByDuration( - searchResults[scene.id], - scene.file.duration ?? undefined - ).map( - (sceneResult, i) => - sceneResult && ( - - setSelectedResult({ - ...selectedResult, - [scene.id]: i, - }) - } - setCoverImage={config.setCoverImage} - tagOperation={config.tagOperation} - setTags={config.setTags} - setScene={handleTaggedScene} - endpoint={selectedEndpoint.endpoint} - queueFingerprintSubmission={queueFingerprintSubmission} - /> - ) - )} -
- ); - } - - return hideUnmatched && fingerprintMatches.length === 0 ? null : ( -
-
-
-
- - - -
- - - -
-
- {mainContent} -
{subContent}
-
-
- {searchResult} -
- ); - }); - - return ( - -
- {fingerprintError} -
- {(getFingerprintCount() > 0 || hideUnmatched) && ( - - )} -
-
- {fingerprintQueue.length > 0 && ( - - )} -
- -
-
{renderScenes()}
-
- ); -}; +import { LOCAL_FORAGE_KEY, ITaggerConfig, initialConfig } from "./constants"; +import { TaggerList } from "./TaggerList"; interface ITaggerProps { scenes: GQL.SlimSceneDataFragment[]; @@ -631,6 +28,38 @@ export const Tagger: React.FC = ({ scenes, queue }) => { const [showConfig, setShowConfig] = useState(false); const [showManual, setShowManual] = useState(false); + const clearSubmissionQueue = (endpoint: string) => { + if (!config) return; + + setConfig({ + ...config, + fingerprintQueue: { + ...config.fingerprintQueue, + [endpoint]: [], + }, + }); + }; + + const [ + submitFingerprints, + { loading: submittingFingerprints }, + ] = GQL.useSubmitStashBoxFingerprintsMutation(); + + const handleFingerprintSubmission = (endpoint: string) => { + if (!config) return; + + return submitFingerprints({ + variables: { + input: { + stash_box_index: getEndpointIndex(endpoint), + scene_ids: config?.fingerprintQueue[endpoint], + }, + }, + }).then(() => { + clearSubmissionQueue(endpoint); + }); + }; + if (!config) return ; const savedEndpointIndex = @@ -645,7 +74,20 @@ export const Tagger: React.FC = ({ scenes, queue }) => { const selectedEndpoint = stashConfig.data?.configuration.general.stashBoxes[selectedEndpointIndex]; + function getEndpointIndex(endpoint: string) { + return ( + stashConfig.data?.configuration.general.stashBoxes.findIndex( + (s) => s.endpoint === endpoint + ) ?? -1 + ); + } + + async function doBoxSearch(searchVal: string) { + return (await stashBoxSceneQuery(searchVal, selectedEndpointIndex)).data; + } + const queueFingerprintSubmission = (sceneId: string, endpoint: string) => { + if (!config) return; setConfig({ ...config, fingerprintQueue: { @@ -655,14 +97,16 @@ export const Tagger: React.FC = ({ scenes, queue }) => { }); }; - const clearSubmissionQueue = (endpoint: string) => { - setConfig({ - ...config, - fingerprintQueue: { - ...config.fingerprintQueue, - [endpoint]: [], - }, - }); + const getQueue = (endpoint: string) => { + if (!config) return []; + return config.fingerprintQueue[endpoint] ?? []; + }; + + const fingerprintQueue = { + queueFingerprintSubmission, + getQueue, + submitFingerprints: handleFingerprintSubmission, + submittingFingerprints, }; return ( @@ -708,8 +152,8 @@ export const Tagger: React.FC = ({ scenes, queue }) => { endpoint: selectedEndpoint.endpoint, index: selectedEndpointIndex, }} - queueFingerprintSubmission={queueFingerprintSubmission} - clearSubmissionQueue={clearSubmissionQueue} + queryScene={doBoxSearch} + fingerprintQueue={fingerprintQueue} /> ) : ( diff --git a/ui/v2.5/src/components/Tagger/TaggerList.tsx b/ui/v2.5/src/components/Tagger/TaggerList.tsx new file mode 100644 index 000000000..0a885df5c --- /dev/null +++ b/ui/v2.5/src/components/Tagger/TaggerList.tsx @@ -0,0 +1,329 @@ +import React, { useEffect, useRef, useState } from "react"; +import { Button, Card } from "react-bootstrap"; +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 { SceneQueue } from "src/models/sceneQueue"; +import { uniqBy } from "lodash"; +import { ITaggerConfig } from "./constants"; +import { selectScenes, IStashBoxScene } from "./utils"; +import { TaggerScene } from "./TaggerScene"; + +interface IFingerprintQueue { + getQueue: (endpoint: string) => string[]; + queueFingerprintSubmission: (sceneId: string, endpoint: string) => void; + submitFingerprints: (endpoint: string) => Promise | undefined; + submittingFingerprints: boolean; +} + +interface ITaggerListProps { + scenes: GQL.SlimSceneDataFragment[]; + queue?: SceneQueue; + selectedEndpoint: { endpoint: string; index: number }; + config: ITaggerConfig; + queryScene: (searchVal: string) => Promise; + fingerprintQueue: IFingerprintQueue; +} + +// Caches fingerprint lookups between page renders +let fingerprintCache: Record = {}; + +function fingerprintSearchResults( + scenes: GQL.SlimSceneDataFragment[], + fingerprints: Record +) { + const ret: Record = {}; + + if (Object.keys(fingerprints).length === 0) { + return ret; + } + + // perform matching here + scenes.forEach((scene) => { + // ignore where scene entry is not in results + if ( + (scene.checksum && fingerprints[scene.checksum] !== undefined) || + (scene.oshash && fingerprints[scene.oshash] !== undefined) || + (scene.phash && fingerprints[scene.phash] !== undefined) + ) { + const fingerprintMatches = uniqBy( + [ + ...(fingerprints[scene.checksum ?? ""] ?? []), + ...(fingerprints[scene.oshash ?? ""] ?? []), + ...(fingerprints[scene.phash ?? ""] ?? []), + ].flat(), + (f) => f.stash_id + ); + + ret[scene.id] = fingerprintMatches; + } else { + delete ret[scene.id]; + } + }); + + return ret; +} + +export const TaggerList: React.FC = ({ + scenes, + queue, + selectedEndpoint, + config, + queryScene, + fingerprintQueue, +}) => { + const intl = useIntl(); + const [fingerprintError, setFingerprintError] = useState(""); + const [loading, setLoading] = useState(false); + const inputForm = useRef(null); + + const [searchErrors, setSearchErrors] = useState< + Record + >({}); + const [taggedScenes, setTaggedScenes] = useState< + Record> + >({}); + const [loadingFingerprints, setLoadingFingerprints] = useState(false); + const [fingerprints, setFingerprints] = useState< + Record + >(fingerprintCache); + const [searchResults, setSearchResults] = useState< + Record + >(fingerprintSearchResults(scenes, fingerprints)); + const [hideUnmatched, setHideUnmatched] = useState(false); + const queuedFingerprints = fingerprintQueue.getQueue( + selectedEndpoint.endpoint + ); + + useEffect(() => { + inputForm?.current?.reset(); + }, [config.mode, config.blacklist]); + + function clearSceneSearchResult(sceneID: string) { + // remove sceneID results from the results object + const { [sceneID]: _removedResult, ...newSearchResults } = searchResults; + const { [sceneID]: _removedError, ...newSearchErrors } = searchErrors; + setSearchResults(newSearchResults); + setSearchErrors(newSearchErrors); + } + + const doSceneQuery = (sceneID: string, searchVal: string) => { + clearSceneSearchResult(sceneID); + + queryScene(searchVal) + .then((queryData) => { + const s = selectScenes(queryData.queryStashBoxScene); + setSearchResults({ + ...searchResults, + [sceneID]: s, + }); + setSearchErrors({ + ...searchErrors, + [sceneID]: undefined, + }); + setLoading(false); + }) + .catch(() => { + setLoading(false); + // Destructure to remove existing result + const { [sceneID]: unassign, ...results } = searchResults; + setSearchResults(results); + setSearchErrors({ + ...searchErrors, + [sceneID]: "Network Error", + }); + }); + + setLoading(true); + }; + + const handleFingerprintSubmission = () => { + fingerprintQueue.submitFingerprints(selectedEndpoint.endpoint); + }; + + const handleTaggedScene = (scene: Partial) => { + setTaggedScenes({ + ...taggedScenes, + [scene.id as string]: scene, + }); + }; + + const handleFingerprintSearch = async () => { + setLoadingFingerprints(true); + + setSearchErrors({}); + setSearchResults({}); + + const newFingerprints = { ...fingerprints }; + + const filteredScenes = scenes.filter((s) => s.stash_ids.length === 0); + const sceneIDs = filteredScenes.map((s) => s.id); + + const results = await stashBoxSceneBatchQuery( + sceneIDs, + selectedEndpoint.index + ).catch(() => { + setLoadingFingerprints(false); + setFingerprintError("Network Error"); + }); + + if (!results) return; + + // clear search errors + setSearchErrors({}); + + selectScenes(results.data?.queryStashBoxScene).forEach((scene) => { + scene.fingerprints?.forEach((f) => { + newFingerprints[f.hash] = newFingerprints[f.hash] + ? [...newFingerprints[f.hash], scene] + : [scene]; + }); + }); + + // Null any ids that are still undefined since it means they weren't found + filteredScenes.forEach((scene) => { + if (scene.oshash) { + newFingerprints[scene.oshash] = newFingerprints[scene.oshash] ?? null; + } + if (scene.checksum) { + newFingerprints[scene.checksum] = + newFingerprints[scene.checksum] ?? null; + } + if (scene.phash) { + newFingerprints[scene.phash] = newFingerprints[scene.phash] ?? null; + } + }); + + const newSearchResults = fingerprintSearchResults(scenes, newFingerprints); + setSearchResults(newSearchResults); + + setFingerprints(newFingerprints); + fingerprintCache = newFingerprints; + setLoadingFingerprints(false); + setFingerprintError(""); + }; + + const canFingerprintSearch = () => + scenes.some( + (s) => + s.stash_ids.length === 0 && + (!s.oshash || fingerprints[s.oshash] === undefined) && + (!s.checksum || fingerprints[s.checksum] === undefined) && + (!s.phash || fingerprints[s.phash] === undefined) + ); + + const getFingerprintCount = () => { + return scenes.filter( + (s) => + s.stash_ids.length === 0 && + ((s.checksum && fingerprints[s.checksum]) || + (s.oshash && fingerprints[s.oshash]) || + (s.phash && fingerprints[s.phash])) + ).length; + }; + + const getFingerprintCountMessage = () => { + const count = getFingerprintCount(); + return intl.formatMessage( + { id: "component_tagger.results.fp_found" }, + { fpCount: count } + ); + }; + + const toggleHideUnmatchedScenes = () => { + setHideUnmatched(!hideUnmatched); + }; + + function generateSceneLink(scene: GQL.SlimSceneDataFragment, index: number) { + return queue + ? queue.makeLink(scene.id, { sceneIndex: index }) + : `/scenes/${scene.id}`; + } + + const renderScenes = () => + scenes.map((scene, index) => { + const sceneLink = generateSceneLink(scene, index); + const searchResult = { + results: searchResults[scene.id], + error: searchErrors[scene.id], + }; + + return ( + doSceneQuery(scene.id, queryString)} + tagScene={handleTaggedScene} + searchResult={searchResult} + /> + ); + }); + + return ( + +
+ {fingerprintError} +
+ {(getFingerprintCount() > 0 || hideUnmatched) && ( + + )} +
+
+ {queuedFingerprints.length > 0 && ( + + )} +
+ +
+
{renderScenes()}
+
+ ); +}; diff --git a/ui/v2.5/src/components/Tagger/TaggerScene.tsx b/ui/v2.5/src/components/Tagger/TaggerScene.tsx new file mode 100644 index 000000000..f06eb8ae0 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/TaggerScene.tsx @@ -0,0 +1,233 @@ +import React, { useRef, useState } from "react"; +import { Button, 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 StashSearchResult from "./StashSearchResult"; +import { ITaggerConfig } from "./constants"; +import { + parsePath, + IStashBoxScene, + sortScenesByDuration, + prepareQueryString, +} from "./utils"; + +export interface ISearchResult { + results?: IStashBoxScene[]; + error?: string; +} + +export interface ITaggerScene { + scene: GQL.SlimSceneDataFragment; + url: string; + config: ITaggerConfig; + searchResult?: ISearchResult; + hideUnmatched?: boolean; + loading?: boolean; + doSceneQuery: (queryString: string) => void; + taggedScene?: Partial; + tagScene: (scene: Partial) => void; + endpoint: string; + queueFingerprintSubmission: (sceneId: string, endpoint: string) => void; +} + +export const TaggerScene: React.FC = ({ + scene, + url, + config, + searchResult, + hideUnmatched, + loading, + doSceneQuery, + taggedScene, + tagScene, + endpoint, + queueFingerprintSubmission, +}) => { + const [selectedResult, setSelectedResult] = useState(0); + + const queryString = useRef(""); + + const searchResults = searchResult?.results ?? []; + const searchError = searchResult?.error; + const emptyResults = + searchResult && searchResult.results && searchResult.results.length === 0; + + const { paths, file, ext } = parsePath(scene.path); + const originalDir = scene.path.slice( + 0, + scene.path.length - file.length - ext.length + ); + const defaultQueryString = prepareQueryString( + scene, + paths, + file, + config.mode, + config.blacklist + ); + + const hasStashIDs = scene.stash_ids.length > 0; + const width = scene.file.width ? scene.file.width : 0; + const height = scene.file.height ? scene.file.height : 0; + const isPortrait = height > width; + + function renderMainContent() { + if (!taggedScene && hasStashIDs) { + return ( +
+
+ +
+
+ ); + } + + if (!taggedScene && !hasStashIDs) { + return ( + + + + + + + ) => { + queryString.current = e.currentTarget.value; + }} + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && + doSceneQuery(queryString.current || defaultQueryString) + } + /> + + + + + ); + } + + if (taggedScene) { + return ( +
+
+ +
+
+ + {taggedScene.title} + +
+
+ ); + } + } + + function renderSubContent() { + if (scene.stash_ids.length > 0) { + const stashLinks = scene.stash_ids.map((stashID) => { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( +
{stashID.stash_id}
+ ); + + return link; + }); + return <>{stashLinks}; + } + + if (searchError) { + return
{searchError}
; + } + + if (emptyResults) { + return ( +
+ +
+ ); + } + } + + function renderSearchResult() { + if (searchResults.length > 0 && !taggedScene) { + return ( +
    + {sortScenesByDuration( + searchResults, + scene.file.duration ?? undefined + ).map( + (sceneResult, i) => + sceneResult && ( + setSelectedResult(i)} + setCoverImage={config.setCoverImage} + tagOperation={config.tagOperation} + setTags={config.setTags} + setScene={tagScene} + endpoint={endpoint} + queueFingerprintSubmission={queueFingerprintSubmission} + /> + ) + )} +
+ ); + } + } + + return hideUnmatched && emptyResults ? null : ( +
+
+
+
+ + + +
+ + + +
+
+ {renderMainContent()} +
{renderSubContent()}
+
+
+ {renderSearchResult()} +
+ ); +}; diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss index 1b42be2a7..f36d00af3 100644 --- a/ui/v2.5/src/components/Tagger/styles.scss +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -179,9 +179,9 @@ &-item { align-items: center; - align-text: left; display: flex; overflow: hidden; + text-align: left; } } diff --git a/ui/v2.5/src/components/Tagger/taggerService.ts b/ui/v2.5/src/components/Tagger/taggerService.ts new file mode 100644 index 000000000..59f22dce7 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/taggerService.ts @@ -0,0 +1,260 @@ +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"; +import { IPerformerOperations } from "./PerformerResult"; +import { StudioOperation } from "./StudioResult"; +import { IStashBoxScene } from "./utils"; + +export interface ITagSceneOptions { + setCoverImage?: boolean; + setTags?: boolean; + tagOperation: string; +} + +export function useTagScene( + options: ITagSceneOptions, + setSaveState: (state: string) => void, + setError: (err: { message?: string; details?: string }) => void +) { + const createStudio = useCreateStudio(); + const createPerformer = useCreatePerformer(); + const createTag = useCreateTag(); + const updatePerformerStashID = useUpdatePerformerStashID(); + const updateStudioStashID = useUpdateStudioStashID(); + const [updateScene] = GQL.useSceneUpdateMutation({ + onError: (e) => { + const message = + e.message === "invalid JPEG format: short Huffman data" + ? "Failed to save scene due to corrupted cover image" + : "Failed to save scene"; + setError({ + message, + details: e.message, + }); + }, + }); + + const { data: allTags } = GQL.useAllTagsForFilterQuery(); + + const handleSave = async ( + stashScene: GQL.SlimSceneDataFragment, + scene: IStashBoxScene, + studio: StudioOperation | undefined, + performers: IPerformerOperations, + endpoint: string + ) => { + setError({}); + let performerIDs = []; + let studioID = null; + + if (!studio) return; + + 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(""); + } + 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"); + performerIDs = await Promise.all( + Object.keys(performers).map(async (stashID) => { + const performer = performers[stashID]; + if (performer.type === "skip") return "Skip"; + + let performerID = performer.data.id; + + if (performer.type === "create") { + const imgurl = performer.data.images[0]; + let imgData = null; + if (imgurl) { + const img = await fetch(imgurl, { + mode: "cors", + cache: "no-store", + }); + if (img.status === 200) { + const blob = await img.blob(); + imgData = await blobToBase64(blob); + } + } + + const performerInput = { + name: performer.data.name, + gender: performer.data.gender, + country: performer.data.country, + height: performer.data.height, + ethnicity: performer.data.ethnicity, + birthdate: performer.data.birthdate, + eye_color: performer.data.eye_color, + fake_tits: performer.data.fake_tits, + measurements: performer.data.measurements, + career_length: performer.data.career_length, + tattoos: performer.data.tattoos, + piercings: performer.data.piercings, + twitter: performer.data.twitter, + instagram: performer.data.instagram, + image: imgData, + stash_ids: [ + { + endpoint, + stash_id: stashID, + }, + ], + details: performer.data.details, + death_date: performer.data.death_date, + hair_color: performer.data.hair_color, + weight: Number(performer.data.weight), + }; + + const res = await createPerformer(performerInput, stashID); + if (!res?.data?.performerCreate) { + setError({ + message: `Failed to save performer "${performerInput.name}"`, + details: res?.errors?.[0].message, + }); + return null; + } + performerID = res.data?.performerCreate.id; + } + + if (performer.type === "update") { + const stashIDs = performer.data.stash_ids; + await updatePerformerStashID(performer.data.id, [ + ...stashIDs, + { stash_id: stashID, endpoint }, + ]); + } + + return performerID; + }) + ); + + 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; + } + + setSaveState(""); + }; + + return handleSave; +} diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts index b14d6bb0f..d403e1156 100644 --- a/ui/v2.5/src/components/Tagger/utils.ts +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -1,5 +1,116 @@ import * as GQL from "src/core/generated-graphql"; import { getCountryByISO } from "src/utils/country"; +import { ParseMode } from "./constants"; + +const months = [ + "jan", + "feb", + "mar", + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", +]; + +const ddmmyyRegex = /\.(\d\d)\.(\d\d)\.(\d\d)\./; +const yyyymmddRegex = /(\d{4})[-.](\d{2})[-.](\d{2})/; +const mmddyyRegex = /(\d{2})[-.](\d{2})[-.](\d{4})/; +const ddMMyyRegex = new RegExp( + `(\\d{1,2}).(${months.join("|")})\\.?.(\\d{4})`, + "i" +); +const MMddyyRegex = new RegExp( + `(${months.join("|")})\\.?.(\\d{1,2}),?.(\\d{4})`, + "i" +); +const parseDate = (input: string): string => { + let output = input; + const ddmmyy = output.match(ddmmyyRegex); + if (ddmmyy) { + output = output.replace( + ddmmyy[0], + ` 20${ddmmyy[1]}-${ddmmyy[2]}-${ddmmyy[3]} ` + ); + } + const mmddyy = output.match(mmddyyRegex); + if (mmddyy) { + output = output.replace( + mmddyy[0], + ` ${mmddyy[1]}-${mmddyy[2]}-${mmddyy[3]} ` + ); + } + const ddMMyy = output.match(ddMMyyRegex); + if (ddMMyy) { + const month = (months.indexOf(ddMMyy[2].toLowerCase()) + 1) + .toString() + .padStart(2, "0"); + output = output.replace( + ddMMyy[0], + ` ${ddMMyy[3]}-${month}-${ddMMyy[1].padStart(2, "0")} ` + ); + } + const MMddyy = output.match(MMddyyRegex); + if (MMddyy) { + const month = (months.indexOf(MMddyy[1].toLowerCase()) + 1) + .toString() + .padStart(2, "0"); + output = output.replace( + MMddyy[0], + ` ${MMddyy[3]}-${month}-${MMddyy[2].padStart(2, "0")} ` + ); + } + + const yyyymmdd = output.search(yyyymmddRegex); + if (yyyymmdd !== -1) + return ( + output.slice(0, yyyymmdd).replace(/-/g, " ") + + output.slice(yyyymmdd, yyyymmdd + 10).replace(/\./g, "-") + + output.slice(yyyymmdd + 10).replace(/-/g, " ") + ); + return output.replace(/-/g, " "); +}; + +export function prepareQueryString( + scene: Partial, + paths: string[], + filename: string, + mode: ParseMode, + blacklist: string[] +) { + if ((mode === "auto" && scene.date && scene.studio) || mode === "metadata") { + let str = [ + scene.date, + scene.studio?.name ?? "", + (scene?.performers ?? []).map((p) => p.name).join(" "), + scene?.title ? scene.title.replace(/[^a-zA-Z0-9 ]+/g, "") : "", + ] + .filter((s) => s !== "") + .join(" "); + blacklist.forEach((b) => { + str = str.replace(new RegExp(b, "gi"), " "); + }); + return str; + } + let s = ""; + + if (mode === "auto" || mode === "filename") { + s = filename; + } else if (mode === "path") { + s = [...paths, filename].join(" "); + } else { + s = paths[paths.length - 1]; + } + blacklist.forEach((b) => { + s = s.replace(new RegExp(b, "gi"), " "); + }); + s = parseDate(s); + return s.replace(/\./g, " "); +} const toTitleCase = (phrase: string) => { return phrase