From 0587f0d02b653994a2fa775c7697ea144ea096e1 Mon Sep 17 00:00:00 2001 From: WeedLordVegeta420 <81525421+WeedLordVegeta420@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:37:15 -0500 Subject: [PATCH] [tagCopyPaste] Refactor to use PluginApi.patch. (#672) --- plugins/tagCopyPaste/README.md | 4 +- plugins/tagCopyPaste/tagCopyPaste.css | 16 +- plugins/tagCopyPaste/tagCopyPaste.js | 230 +++++++++++++------------- plugins/tagCopyPaste/tagCopyPaste.yml | 4 +- 4 files changed, 129 insertions(+), 125 deletions(-) diff --git a/plugins/tagCopyPaste/README.md b/plugins/tagCopyPaste/README.md index 6a5189d..c4305ee 100644 --- a/plugins/tagCopyPaste/README.md +++ b/plugins/tagCopyPaste/README.md @@ -2,7 +2,7 @@ https://discourse.stashapp.cc/t/tagcopypaste/1858 -This plugin adds Copy and Paste functionality to the Tags input field that allows for easier bulk adding and copying of tags, with the goal of making it easy to copy Tags between objects, bulk load manually created tag lists, or load tag lists copied from AI tagger output. +This plugin adds Copy and Paste functionality to the Tags input field with the goal of making it easy to copy Tags between objects, bulk load manually created tag lists, or load tag lists copied from AI tagger output. Copy/Paste of Tags can be performed either with dedicated Copy/Paste buttons or by selecting the Tag input field and performing the typical CTRL+C/CTRL+V. @@ -14,5 +14,5 @@ Pasting will check your current clipboard for a comma and/or newline delimited s ## Config Options: - **Create If Not Exists**: If enabled, new tags will be created when pasted list contains entries that do not already exist. DEFAULT: Disabled -- **Require Confirmation**: If enabled, user needs to confirm paste before changes are saved. DEFAULT: Disabled +- **Require Confirmation**: If enabled, user needs to confirm new tags being created. DEFAULT: Disabled diff --git a/plugins/tagCopyPaste/tagCopyPaste.css b/plugins/tagCopyPaste/tagCopyPaste.css index b13a583..44be282 100644 --- a/plugins/tagCopyPaste/tagCopyPaste.css +++ b/plugins/tagCopyPaste/tagCopyPaste.css @@ -1,8 +1,10 @@ -button.imageGalleryNav-copyButton, -button.imageGalleryNav-pasteButton { - float: right; - height: 21px; - line-height: 20px; - padding: 0 10px; - margin-right: 15px; +div.multi-set { + position: relative; } + +div.modal-content div.tagCopyPaste div.btn-group { + position: absolute; + top:0; + right:0; +} + diff --git a/plugins/tagCopyPaste/tagCopyPaste.js b/plugins/tagCopyPaste/tagCopyPaste.js index 42e7649..0ec26f2 100644 --- a/plugins/tagCopyPaste/tagCopyPaste.js +++ b/plugins/tagCopyPaste/tagCopyPaste.js @@ -1,21 +1,24 @@ (async () => { + const PluginApi = window.PluginApi; + const React = PluginApi.React; + let pluginSettings = {}; const defaultPluginSettings = { createIfNotExists: false, requireConfirmation: false, }; - var objID = null; - var objType = null; + // Helper functions for handling array of tags. + const getTagNameArray = (tagArray) => tagArray.map((value) => value.name); + const getTagNameString = (tagArray) => getTagNameArray(tagArray).join(", "); + const sortTagArray = (tagArray) => + tagArray.sort((a, b) => { + var aCompStr = a.sort_name ? a.sort_name : a.name; + var bCompStr = b.sort_name ? b.sort_name : b.name; + return aCompStr.localeCompare(bCompStr); + }); - // helper function to get the innerText of all elements matching a selector - const getAllInnerText = (selector) => Array.from(document.querySelectorAll(selector)) - .map((el) => el.innerText.trim()) - .filter((text) => text !== ""); - - // On image page, get data about gallery (image's position within gallery, next/prev image IDs), - // add arrow buttons to page, and register arrow keypress handlers, - async function setupTagCopyPaste(objTypeTriggered) { + async function setupTagCopyPaste() { // Get plugin settings. const configSettings = await csLib.getConfiguration("tagCopyPaste", {}); // getConfiguration is from cs-ui-lib.js pluginSettings = { @@ -23,81 +26,110 @@ ...configSettings, }; - objID = window.location.pathname.split("/")[2]; - objType = objTypeTriggered; + // Patch TagSelect to add copy/paste buttons. + PluginApi.patch.after("TagSelect", function (props, _, originalComponent) { + const copyButtonRef = React.useRef(null); + const pasteButtonRef = React.useRef(null); + const propsRef = props; - // Add UI elements. - if (objID !== "new") { - insertCopyPasteButtons(); - } - } + // Copy Button click handler + const copyClickHandler = (event) => { + event.preventDefault(); + handleCopyClick(propsRef.values); + }; - function copyEventHandler(event) { - event.preventDefault(); - handleCopyClick(); - } + // Paste Button click handler + const pasteClickHandler = (event) => { + event.preventDefault(); + handlePasteClick(propsRef.onSelect, propsRef.values); + }; - function pasteEventHandler(event) { - event.preventDefault(); - handlePasteClick(); - } + React.useEffect(() => { + // Not the ideal way to handle this, but it works. + // Wait for the buttons to render and then add the onCopy/onPaste handlers to select control DOM element. + if (copyButtonRef && copyButtonRef.current) { + var mainCopyPasteWrapper = + copyButtonRef.current.parentElement.parentElement; + var tagInputBox = mainCopyPasteWrapper.querySelector( + ".react-select__value-container", + ); - function insertCopyPasteButtons() { - // listen for copy and paste events within tag input box - // find tag input box - const tagInputBox = document.querySelector("label[for='tag_ids'] + div .react-select__value-container"); - if (tagInputBox) { - tagInputBox.removeEventListener("copy", copyEventHandler); - tagInputBox.removeEventListener("paste", pasteEventHandler); - tagInputBox.addEventListener("copy", copyEventHandler); - tagInputBox.addEventListener("paste", pasteEventHandler); - } + const copyEventHandler = (e) => { + e.preventDefault(); + copyButtonRef.current.click(); + }; - var copyButton = document.createElement("button"); - copyButton.className = "imageGalleryNav-copyButton btn btn-secondary"; - copyButton.innerText = "Copy"; - copyButton.onclick = (event) => { - event.preventDefault(); - handleCopyClick(); - } + const pasteEventHandler = (e) => { + e.preventDefault(); + pasteButtonRef.current.click(); + }; - var pasteButton = document.createElement("button"); - pasteButton.className = "imageGalleryNav-pasteButton btn btn-secondary"; - pasteButton.innerText = "Paste"; - pasteButton.onclick = (event) => { - event.preventDefault(); - handlePasteClick(); - } + if (tagInputBox) { + tagInputBox.addEventListener("copy", copyEventHandler); + tagInputBox.addEventListener("paste", pasteEventHandler); + } + } + }, []); - if (document.querySelector("button.imageGalleryNav-pasteButton") == null) { - document.querySelector("label[for='tag_ids']").append(pasteButton); - } - if (document.querySelector("button.imageGalleryNav-copyButton") == null) { - document.querySelector("label[for='tag_ids']").append(copyButton); - } + return React.createElement("div", { className: "tagCopyPaste" }, [ + React.createElement( + "div", + { + className: "btn-group", + }, + [ + React.createElement( + "button", + { + type: "button", + ref: copyButtonRef, + onClick: copyClickHandler, + className: + "imageGalleryNav-copyButton btn btn-secondary btn-sm", + }, + "Copy", + ), + React.createElement( + "button", + { + type: "button", + ref: pasteButtonRef, + onClick: pasteClickHandler, + className: + "imageGalleryNav-pasteButton btn btn-secondary btn-sm", + }, + "Paste", + ), + ], + ), + originalComponent, + ]); + }); } // Handle copy click. Return delimited list of current tags. - async function handleCopyClick() { + async function handleCopyClick(propValues) { // Get tags from input box // join as comma delimited list - const tagList = getAllInnerText("label[for='tag_ids'] + div .react-select__multi-value__label").join(",") - // write to clipboard. + const tagList = getTagNameString(propValues); navigator.clipboard.writeText(tagList); } // Handle paste click. - async function handlePasteClick() { + async function handlePasteClick(onSelect, propValues) { // Parse tag list from comma delimited string. const tagInput = await navigator.clipboard.readText(); - var inputTagList = tagInput.split(/\r?\n|\r|,/).map(s => s.trim()).filter((text) => text !== "") // do de-duplication later + var inputTagList = tagInput + .split(/\r?\n|\r|,/) + .map((s) => s.trim()) + .filter((text) => text !== ""); // do de-duplication later // Get tags from input box and also add to tag list. - const existingTagList = getAllInnerText("label[for='tag_ids'] + div .react-select__multi-value__label"); + const existingTagList = getTagNameArray(propValues); inputTagList = [...new Set([...inputTagList, ...existingTagList])].sort(); - var missingTags = []; + var missingTagNames = []; var existingTags = []; var tagUpdateList = []; @@ -108,59 +140,42 @@ existingTags.push(inputTag); tagUpdateList.push(tagID[0]); } else { - missingTags.push(inputTag); + missingTagNames.push(inputTag); } } - if (pluginSettings.requireConfirmation) { - const missingTagsStr = missingTags.join(", "); - const existingTagsStr = existingTags.join(", "); - const msg = pluginSettings.createIfNotExists - ? `Missing Tags that will be created:\n${missingTagsStr}\n\nExisting Tags that will be saved: \n${existingTagsStr}\n\nContinue?` - : `Missing Tags that will be skipped:\n${missingTagsStr}\n\nExisting Tags that will be saved: \n${existingTagsStr}\n\nContinue?`; - - if (!confirm(msg)) { - return; + // Create missing tags if enabled. Prompt user to confirm if confirmation option is also enabled. + const missingTagsStr = missingTagNames.join(", "); + const msg = `Missing Tags that will be created:\n${missingTagsStr}\n\nContinue?`; + if ( + pluginSettings.createIfNotExists && + missingTagNames.length && + (!pluginSettings.requireConfirmation || confirm(msg)) + ) { + for (const missingTagName of missingTagNames) { + const newTag = await createNewTag(missingTagName); + if (newTag != null) tagUpdateList.push(newTag); } } - if (pluginSettings.createIfNotExists && missingTags.length) { - for (const missingTag of missingTags) { - const newTagID = await createNewTag(missingTag); - if (newTagID != null) tagUpdateList.push(newTagID); - } - } - - // Update tags on object with new tag ID list. - await updateObjTags( - tagUpdateList, - `${objType.toLowerCase()}Update`, - `${objType}UpdateInput` - ); - - window.location.reload(); + // Update TagSelect control with new tag list. + onSelect(sortTagArray(tagUpdateList)); } // *** GQL Calls *** - // Update Object by ID, new tags list, and GQL mutation name. - async function updateObjTags(tags, fnName, inputName) { - const variables = { input: { id: objID, tag_ids: tags } }; - const query = `mutation UpdateObj($input:${inputName}!) { ${fnName}(input: $input) {id} }`; - return await csLib.callGQL({ query, variables }); - } - - // Update Object by ID, new tags list, and GQL mutation name. + // Create new tag. + // Return newly created tag object. async function createNewTag(tagName) { const variables = { input: { name: tagName } }; - const query = `mutation CreateTag($input:TagCreateInput!) { tagCreate(input: $input) {id} }`; + const query = `mutation CreateTag($input:TagCreateInput!) { tagCreate(input: $input) { id, name, sort_name, favorite, description, aliases, image_path, parents {id, name}, stash_ids {endpoint, stash_id, updated_at } } }`; return await csLib .callGQL({ query, variables }) - .then((data) => data.tagCreate.id); + .then((data) => data.tagCreate); } // Find Tag by name/alias. - // Return match tag ID. + // Return matched list of tag objects. async function getTagByName(tagName) { const tagFilter = { name: { value: tagName, modifier: "EQUALS" }, @@ -168,24 +183,11 @@ }; const findFilter = { per_page: -1, sort: "name" }; const variables = { tag_filter: tagFilter, filter: findFilter }; - const query = `query ($tag_filter: TagFilterType!, $filter: FindFilterType!) { findTags(filter: $filter, tag_filter: $tag_filter) { tags { id } } }`; + const query = `query ($tag_filter: TagFilterType!, $filter: FindFilterType!) { findTags(filter: $filter, tag_filter: $tag_filter) { tags { id, name, sort_name, favorite, description, aliases, image_path, parents {id, name}, stash_ids {endpoint, stash_id, updated_at } } } }`; return await csLib .callGQL({ query, variables }) - .then((data) => data.findTags.tags.map((item) => item.id)); + .then((data) => data.findTags.tags); } - // listener arrays - [ - [ "/scenes/", "[id*='-edit-details']", "Scene" ], - [ "/studios/", "[id='studio-edit']", "Studio" ], - [ "/groups/", "[id='group-edit']", "Group" ], - [ "/performers/", "[id='performer-edit']", "Performer" ], - [ "/galleries/", "[id*='-edit-details']", "Gallery" ], - [ "/images/", "[id*='-edit-details']", "Image" ] - ].forEach(([path, selector, objTypeTriggered]) => { - // Wait for the page to load and the element to be present. - csLib.PathElementListener(path, selector, () => { - setupTagCopyPaste(objTypeTriggered); - }); // PathElementListener is from cs-ui-lib.js - }); + setupTagCopyPaste(); })(); diff --git a/plugins/tagCopyPaste/tagCopyPaste.yml b/plugins/tagCopyPaste/tagCopyPaste.yml index e6590aa..6bcdf51 100644 --- a/plugins/tagCopyPaste/tagCopyPaste.yml +++ b/plugins/tagCopyPaste/tagCopyPaste.yml @@ -1,7 +1,7 @@ name: tagCopyPaste # requires: CommunityScriptsUILibrary description: Adds Copy/Paste buttons to Tags field. -version: 0.4 +version: 0.5 url: https://discourse.stashapp.cc/t/tagcopypaste/1858 settings: createIfNotExists: @@ -10,7 +10,7 @@ settings: type: BOOLEAN requireConfirmation: displayName: Require Confirmation - description: If enabled, user needs to confirm paste before changes are saved. + description: If enabled, user needs to confirm new tags being created. type: BOOLEAN ui: requires: