import { Badge, Button, Card, Collapse, Dropdown, DropdownButton, Form, Table, Spinner } from 'react-bootstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import React, { useEffect, useState } from "react"; import { StashService } from "../../core/StashService"; import * as GQL from "../../core/generated-graphql"; import { SlimSceneDataFragment, Maybe } from "../../core/generated-graphql"; import { TextUtils } from "../../utils/text"; import _ from "lodash"; import { ToastUtils } from "../../utils/toasts"; import { ErrorUtils } from "../../utils/errors"; import { Pagination } from "../list/Pagination"; import { FilterMultiSelect } from "../select/FilterMultiSelect"; import { FilterSelect } from "../select/FilterSelect"; class ParserResult { public value: Maybe; public originalValue: Maybe; public set: boolean = false; public setOriginalValue(v : Maybe) { this.originalValue = v; this.value = v; } public setValue(v : Maybe) { if (!!v) { this.value = v; this.set = !_.isEqual(this.value, this.originalValue); } } } class ParserField { public field : string; public helperText? : string; constructor(field: string, helperText?: string) { this.field = field; this.helperText = helperText; } public getFieldPattern() { return "{" + this.field + "}"; } static Title = new ParserField("title"); static Ext = new ParserField("ext", "File extension"); static I = new ParserField("i", "Matches any ignored word"); static D = new ParserField("d", "Matches any delimiter (.-_)"); static Performer = new ParserField("performer"); static Studio = new ParserField("studio"); static Tag = new ParserField("tag"); // date fields static Date = new ParserField("date", "YYYY-MM-DD"); static YYYY = new ParserField("yyyy", "Year"); static YY = new ParserField("yy", "Year (20YY)"); static MM = new ParserField("mm", "Two digit month"); static DD = new ParserField("dd", "Two digit date"); static YYYYMMDD = new ParserField("yyyymmdd"); static YYMMDD = new ParserField("yymmdd"); static DDMMYYYY = new ParserField("ddmmyyyy"); static DDMMYY = new ParserField("ddmmyy"); static MMDDYYYY = new ParserField("mmddyyyy"); static MMDDYY = new ParserField("mmddyy"); static validFields = [ ParserField.Title, ParserField.Ext, ParserField.D, ParserField.I, ParserField.Performer, ParserField.Studio, ParserField.Tag, ParserField.Date, ParserField.YYYY, ParserField.YY, ParserField.MM, ParserField.DD, ParserField.YYYYMMDD, ParserField.YYMMDD, ParserField.DDMMYYYY, ParserField.DDMMYY, ParserField.MMDDYYYY, ParserField.MMDDYY ] static fullDateFields = [ ParserField.YYYYMMDD, ParserField.YYMMDD, ParserField.DDMMYYYY, ParserField.DDMMYY, ParserField.MMDDYYYY, ParserField.MMDDYY ]; } class SceneParserResult { public id: string; public filename: string; public title: ParserResult = new ParserResult(); public date: ParserResult = new ParserResult(); public studio: ParserResult = new ParserResult(); public studioId: ParserResult = new ParserResult(); public tags: ParserResult = new ParserResult(); public tagIds: ParserResult = new ParserResult(); public performers: ParserResult = new ParserResult(); public performerIds: ParserResult = new ParserResult(); public scene : SlimSceneDataFragment; constructor(result : GQL.ParseSceneFilenamesResults) { this.scene = result.scene; this.id = this.scene.id; this.filename = TextUtils.fileNameFromPath(this.scene.path); this.title.setOriginalValue(this.scene.title); this.date.setOriginalValue(this.scene.date); this.performerIds.setOriginalValue(this.scene.performers.map((p) => p.id)); this.performers.setOriginalValue(this.scene.performers); this.tagIds.setOriginalValue(this.scene.tags.map((t) => t.id)); this.tags.setOriginalValue(this.scene.tags); this.studioId.setOriginalValue(this.scene.studio ? this.scene.studio.id : undefined); this.studio.setOriginalValue(this.scene.studio); this.title.setValue(result.title); this.date.setValue(result.date); this.performerIds.setValue(result.performer_ids); this.tagIds.setValue(result.tag_ids); this.studioId.setValue(result.studio_id); if (result.performer_ids) { this.performers.setValue(result.performer_ids.map((p) => { return { id: p, name: "", favorite: false, image_path: "" }; })); } if (result.tag_ids) { this.tags.setValue(result.tag_ids.map((t) => { return { id: t, name: "", }; })); } if (result.studio_id) { this.studio.setValue({ id: result.studio_id, name: "", image_path: "" }); } } private static setInput(object: any, key: string, parserResult : ParserResult) { if (parserResult.set) { object[key] = parserResult.value; } } // returns true if any of its fields have set == true public isChanged() { return this.title.set || this.date.set || this.performerIds.set || this.studioId.set || this.tagIds.set; } public toSceneUpdateInput() { var ret = { id: this.id, title: this.scene.title, details: this.scene.details, url: this.scene.url, date: this.scene.date, rating: this.scene.rating, gallery_id: this.scene.gallery ? this.scene.gallery.id : undefined, studio_id: this.scene.studio ? this.scene.studio.id : undefined, performer_ids: this.scene.performers.map((performer) => performer.id), tag_ids: this.scene.tags.map((tag) => tag.id) }; SceneParserResult.setInput(ret, "title", this.title); SceneParserResult.setInput(ret, "date", this.date); SceneParserResult.setInput(ret, "performer_ids", this.performerIds); SceneParserResult.setInput(ret, "studio_id", this.studioId); SceneParserResult.setInput(ret, "tag_ids", this.tagIds); return ret; } }; interface IParserInput { pattern: string, ignoreWords: string[], whitespaceCharacters: string, capitalizeTitle: boolean, page: number, pageSize: number, findClicked: boolean } interface IParserRecipe { pattern: string, ignoreWords: string[], whitespaceCharacters: string, capitalizeTitle: boolean, description: string } const builtInRecipes = [ { pattern: "{title}", ignoreWords: [], whitespaceCharacters: "", capitalizeTitle: false, description: "Filename" }, { pattern: "{title}.{ext}", ignoreWords: [], whitespaceCharacters: "", capitalizeTitle: false, description: "Without extension" }, { pattern: "{}.{yy}.{mm}.{dd}.{title}.XXX.{}.{ext}", ignoreWords: [], whitespaceCharacters: ".", capitalizeTitle: true, description: "" }, { pattern: "{}.{yy}.{mm}.{dd}.{title}.{ext}", ignoreWords: [], whitespaceCharacters: ".", capitalizeTitle: true, description: "" }, { pattern: "{title}.XXX.{}.{ext}", ignoreWords: [], whitespaceCharacters: ".", capitalizeTitle: true, description: "" }, { pattern: "{}.{yy}.{mm}.{dd}.{title}.{i}.{ext}", ignoreWords: ["cz", "fr"], whitespaceCharacters: ".", capitalizeTitle: true, description: "Foreign language" } ]; export const SceneFilenameParser: React.FC = () => { const [parserResult, setParserResult] = useState([]); const [parserInput, setParserInput] = useState(initialParserInput()); const [allTitleSet, setAllTitleSet] = useState(false); const [allDateSet, setAllDateSet] = useState(false); const [allPerformerSet, setAllPerformerSet] = useState(false); const [allTagSet, setAllTagSet] = useState(false); const [allStudioSet, setAllStudioSet] = useState(false); const [showFields, setShowFields] = useState>(initialShowFieldsState()); const [totalItems, setTotalItems] = useState(0); // Network state const [isLoading, setIsLoading] = useState(false); const updateScenes = StashService.useScenesUpdate(getScenesUpdateData()); function initialParserInput() { return { pattern: "{title}.{ext}", ignoreWords: [], whitespaceCharacters: "._", capitalizeTitle: true, page: 1, pageSize: 20, findClicked: false }; } function initialShowFieldsState() { return new Map([ ["Title", true], ["Date", true], ["Performers", true], ["Tags", true], ["Studio", true] ]); } function getParserFilter() { return { q: parserInput.pattern, page: parserInput.page, per_page: parserInput.pageSize, sort: "path", direction: GQL.SortDirectionEnum.Asc, }; } function getParserInput() { return { ignoreWords: parserInput.ignoreWords, whitespaceCharacters: parserInput.whitespaceCharacters, capitalizeTitle: parserInput.capitalizeTitle }; } async function onFind() { setParserResult([]); setIsLoading(true); try { const response = await StashService.queryParseSceneFilenames(getParserFilter(), getParserInput()); let result = response.data.parseSceneFilenames; if (!!result) { parseResults(result.results); setTotalItems(result.count); } } catch (err) { ErrorUtils.handle(err); } setIsLoading(false); } useEffect(() => { if(parserInput.findClicked) { onFind(); } }, [parserInput]); function onPageSizeChanged(newSize : number) { var newInput = _.clone(parserInput); newInput.page = 1; newInput.pageSize = newSize; setParserInput(newInput); } function onPageChanged(newPage : number) { if (newPage !== parserInput.page) { var newInput = _.clone(parserInput); newInput.page = newPage; setParserInput(newInput); } } function onFindClicked(input : IParserInput) { input.page = 1; input.findClicked = true; setParserInput(input); setTotalItems(0); } function getScenesUpdateData() { return parserResult.filter((result) => result.isChanged()).map((result) => result.toSceneUpdateInput()); } async function onApply() { setIsLoading(true); try { await updateScenes(); ToastUtils.success("Updated scenes"); } catch (e) { ErrorUtils.handle(e); } setIsLoading(false); } function parseResults(results : GQL.ParseSceneFilenamesResults[]) { if (results) { var result = results.map((r) => { return new SceneParserResult(r); }).filter((r) => !!r) as SceneParserResult[]; setParserResult(result); determineFieldsToHide(); } } function determineFieldsToHide() { var pattern = parserInput.pattern; var titleSet = pattern.includes("{title}"); var dateSet = pattern.includes("{date}") || pattern.includes("{dd}") || // don't worry about other partial date fields since this should be implied ParserField.fullDateFields.some((f) => { return pattern.includes("{" + f.field + "}"); }); var performerSet = pattern.includes("{performer}"); var tagSet = pattern.includes("{tag}"); var studioSet = pattern.includes("{studio}"); var showFieldsCopy = _.clone(showFields); showFieldsCopy.set("Title", titleSet); showFieldsCopy.set("Date", dateSet); showFieldsCopy.set("Performers", performerSet); showFieldsCopy.set("Tags", tagSet); showFieldsCopy.set("Studio", studioSet); setShowFields(showFieldsCopy); } useEffect(() => { var newAllTitleSet = !parserResult.some((r) => { return !r.title.set; }); var newAllDateSet = !parserResult.some((r) => { return !r.date.set; }); var newAllPerformerSet = !parserResult.some((r) => { return !r.performerIds.set; }); var newAllTagSet = !parserResult.some((r) => { return !r.tagIds.set; }); var newAllStudioSet = !parserResult.some((r) => { return !r.studioId.set; }); if (newAllTitleSet != allTitleSet) { setAllTitleSet(newAllTitleSet); } if (newAllDateSet != allDateSet) { setAllDateSet(newAllDateSet); } if (newAllPerformerSet != allPerformerSet) { setAllTagSet(newAllPerformerSet); } if (newAllTagSet != allTagSet) { setAllTagSet(newAllTagSet); } if (newAllStudioSet != allStudioSet) { setAllStudioSet(newAllStudioSet); } }, [parserResult]); function onSelectAllTitleSet(selected : boolean) { var newResult = [...parserResult]; newResult.forEach((r) => { r.title.set = selected; }); setParserResult(newResult); setAllTitleSet(selected); } function onSelectAllDateSet(selected : boolean) { var newResult = [...parserResult]; newResult.forEach((r) => { r.date.set = selected; }); setParserResult(newResult); setAllDateSet(selected); } function onSelectAllPerformerSet(selected : boolean) { var newResult = [...parserResult]; newResult.forEach((r) => { r.performerIds.set = selected; }); setParserResult(newResult); setAllPerformerSet(selected); } function onSelectAllTagSet(selected : boolean) { var newResult = [...parserResult]; newResult.forEach((r) => { r.tagIds.set = selected; }); setParserResult(newResult); setAllTagSet(selected); } function onSelectAllStudioSet(selected : boolean) { var newResult = [...parserResult]; newResult.forEach((r) => { r.studioId.set = selected; }); setParserResult(newResult); setAllStudioSet(selected); } interface IShowFieldsProps { fields: Map onShowFieldsChanged: (fields : Map) => void } function ShowFields(props: IShowFieldsProps) { const [open, setOpen] = useState(false); function handleClick(label: string) { const copy = new Map(props.fields); copy.set(label, !props.fields.get(label)); props.onShowFieldsChanged(copy); } const fieldRows = [...props.fields.entries()].map(([label, enabled]) => (
{handleClick(label)}}> {label}
)); return (
setOpen(!open)}> Display fields
{fieldRows}
); } interface IParserInputProps { input: IParserInput, onFind: (input : IParserInput) => void } function ParserInput(props : IParserInputProps) { const [pattern, setPattern] = useState(props.input.pattern); const [ignoreWords, setIgnoreWords] = useState(props.input.ignoreWords.join(" ")); const [whitespaceCharacters, setWhitespaceCharacters] = useState(props.input.whitespaceCharacters); const [capitalizeTitle, setCapitalizeTitle] = useState(props.input.capitalizeTitle); function onFind() { props.onFind({ pattern: pattern, ignoreWords: ignoreWords.split(" "), whitespaceCharacters: whitespaceCharacters, capitalizeTitle: capitalizeTitle, page: 1, pageSize: props.input.pageSize, findClicked: props.input.findClicked }); } function setParserRecipe(recipe: IParserRecipe) { setPattern(recipe.pattern); setIgnoreWords(recipe.ignoreWords.join(" ")); setWhitespaceCharacters(recipe.whitespaceCharacters); setCapitalizeTitle(recipe.capitalizeTitle); } const validFields = [new ParserField("", "Wildcard")].concat(ParserField.validFields); function addParserField(field: ParserField) { setPattern(pattern + field.getFieldPattern()); } const PAGE_SIZE_OPTIONS = ["20", "40", "60", "120"]; return ( setPattern(newValue.target.value)} value={pattern} /> { validFields.map(item => ( addParserField(item)}> {item.field}{item.helperText} ))}
Use '\\' to escape literal {} characters
Ignored words:: setIgnoreWords(newValue.target.value)} value={ignoreWords} />
Matches with {"{i}"}
Title
Whitespace characters: setWhitespaceCharacters(newValue.target.value)} value={whitespaceCharacters} /> Capitalize title setCapitalizeTitle(!capitalizeTitle)} />
These characters will be replaced with whitespace in the title
{/* TODO - mapping stuff will go here */} { builtInRecipes.map(item => ( setParserRecipe(item)}> {item.pattern}{item.description} ))} setShowFields(fields)} /> onPageSizeChanged(parseInt(event.target.value))} defaultValue={props.input.pageSize} className="filter-item" > { PAGE_SIZE_OPTIONS.map(val => ) }
); } interface ISceneParserFieldProps { parserResult : ParserResult className? : string fieldName : string onSetChanged : (set : boolean) => void onValueChanged : (value : any) => void originalParserResult? : ParserResult renderOriginalInputField: (props : ISceneParserFieldProps) => JSX.Element renderNewInputField: (props : ISceneParserFieldProps, onChange : (event : any) => void) => JSX.Element } function SceneParserField(props : ISceneParserFieldProps) { function maybeValueChanged(value : any) { if (value !== props.parserResult.value) { props.onValueChanged(value); } } if (!showFields.get(props.fieldName)) { return null; } return ( <> {props.onSetChanged(!props.parserResult.set)}} /> {props.renderOriginalInputField(props)} {props.renderNewInputField(props, (value) => maybeValueChanged(value))} ); } function renderOriginalInputGroup(props: ISceneParserFieldProps) { var parserResult = props.originalParserResult || props.parserResult; return ( ); } interface IInputGroupWrapperProps { parserResult: ParserResult onChange : (event : any) => void className? : string } function InputGroupWrapper(props: IInputGroupWrapperProps) { return ( props.onChange(event.target.value)} /> ); } function renderNewInputGroup(props : ISceneParserFieldProps, onChange : (value : any) => void) { return ( {onChange(value)}} parserResult={props.parserResult} /> ); } interface HasName { name: string } function renderOriginalSelect(props : ISceneParserFieldProps) { const parserResult = props.originalParserResult || props.parserResult; const elements = parserResult.originalValue ? Array.isArray(parserResult.originalValue) ? parserResult.originalValue.map((el:HasName) => el.name) : parserResult.originalValue.name : []; return (
{ elements.map((name:string) => {name}) }
); } function renderNewMultiSelect(type: "performers" | "tags", props : ISceneParserFieldProps, onChange : (value : any) => void) { return ( { const ids = items.map((i) => i.id); onChange(ids); }} initialIds={props.parserResult.value} /> ); } function renderNewPerformerSelect(props : ISceneParserFieldProps, onChange : (value : any) => void) { return renderNewMultiSelect("performers", props, onChange); } function renderNewTagSelect(props : ISceneParserFieldProps, onChange : (value : any) => void) { return renderNewMultiSelect("tags", props, onChange); } function renderNewStudioSelect(props : ISceneParserFieldProps, onChange : (value : any) => void) { return ( onChange(item ? item.id : undefined)} initialId={props.parserResult.value} /> ); } interface ISceneParserRowProps { scene : SceneParserResult, onChange: (changedScene : SceneParserResult) => void } function SceneParserRow(props : ISceneParserRowProps) { function changeParser(result : ParserResult, set : boolean, value : any) { var newParser = _.clone(result); newParser.set = set; newParser.value = value; return newParser; } function onTitleChanged(set : boolean, value: string | undefined) { var newResult = _.clone(props.scene); newResult.title = changeParser(newResult.title, set, value); props.onChange(newResult); } function onDateChanged(set : boolean, value: string | undefined) { var newResult = _.clone(props.scene); newResult.date = changeParser(newResult.date, set, value); props.onChange(newResult); } function onPerformerIdsChanged(set : boolean, value: string[] | undefined) { var newResult = _.clone(props.scene); newResult.performerIds = changeParser(newResult.performerIds, set, value); props.onChange(newResult); } function onTagIdsChanged(set : boolean, value: string[] | undefined) { var newResult = _.clone(props.scene); newResult.tagIds = changeParser(newResult.tagIds, set, value); props.onChange(newResult); } function onStudioIdChanged(set : boolean, value: string | undefined) { var newResult = _.clone(props.scene); newResult.studioId = changeParser(newResult.studioId, set, value); props.onChange(newResult); } return ( {props.scene.filename} onTitleChanged(set, props.scene.title.value)} onValueChanged={(value) => onTitleChanged(props.scene.title.set, value)} renderOriginalInputField={renderOriginalInputGroup} renderNewInputField={renderNewInputGroup} /> onDateChanged(set, props.scene.date.value)} onValueChanged={(value) => onDateChanged(props.scene.date.set, value)} renderOriginalInputField={renderOriginalInputGroup} renderNewInputField={renderNewInputGroup} /> onPerformerIdsChanged(set, props.scene.performerIds.value)} onValueChanged={(value) => onPerformerIdsChanged(props.scene.performerIds.set, value)} renderOriginalInputField={renderOriginalSelect} renderNewInputField={renderNewPerformerSelect} /> onTagIdsChanged(set, props.scene.tagIds.value)} onValueChanged={(value) => onTagIdsChanged(props.scene.tagIds.set, value)} renderOriginalInputField={renderOriginalSelect} renderNewInputField={renderNewTagSelect} /> onStudioIdChanged(set, props.scene.studioId.value)} onValueChanged={(value) => onStudioIdChanged(props.scene.studioId.set, value)} renderOriginalInputField={renderOriginalSelect} renderNewInputField={renderNewStudioSelect} /> ) } function onChange(scene : SceneParserResult, changedScene : SceneParserResult) { var newResult = [...parserResult]; var index = newResult.indexOf(scene); newResult[index] = changedScene; setParserResult(newResult); } function renderHeader(fieldName: string, allSet: boolean, onAllSet: (set: boolean) => void) { if (!showFields.get(fieldName)) { return null; } return ( <> {onAllSet(!allSet)}} /> {fieldName} ) } function renderTable() { if (parserResult.length == 0) { return undefined; } return ( <>
{renderHeader("Title", allTitleSet, onSelectAllTitleSet)} {renderHeader("Date", allDateSet, onSelectAllDateSet)} {renderHeader("Performers", allPerformerSet, onSelectAllPerformerSet)} {renderHeader("Tags", allTagSet, onSelectAllTagSet)} {renderHeader("Studio", allStudioSet, onSelectAllStudioSet)} {parserResult.map((scene) => onChange(scene, changedScene)}/> )}
Filename
onPageChanged(page)} />
) } return (

Scene Filename Parser

onFindClicked(input)} /> {isLoading ? : undefined} {renderTable()}
); };