Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions plugins/tagCopyPaste/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

16 changes: 9 additions & 7 deletions plugins/tagCopyPaste/tagCopyPaste.css
Original file line number Diff line number Diff line change
@@ -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;
}

246 changes: 124 additions & 122 deletions plugins/tagCopyPaste/tagCopyPaste.js
Original file line number Diff line number Diff line change
@@ -1,103 +1,135 @@
(async () => {
const PluginApi = window.PluginApi;
const React = PluginApi.React;

let pluginSettings = {};
const defaultPluginSettings = {
createIfNotExists: false,
requireConfirmation: false,
};

var objID = null;
var objType = null;

// 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) {
// 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);
});

async function setupTagCopyPaste() {
// Get plugin settings.
const configSettings = await csLib.getConfiguration("tagCopyPaste", {}); // getConfiguration is from cs-ui-lib.js
pluginSettings = {
...defaultPluginSettings,
...configSettings,
};

objID = window.location.pathname.split("/")[2];
objType = objTypeTriggered;

// Add UI elements.
if (objID !== "new") {
insertCopyPasteButtons();
}
}

function copyEventHandler(event) {
event.preventDefault();
handleCopyClick();
}

function pasteEventHandler(event) {
event.preventDefault();
handlePasteClick();
}

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);
}

var copyButton = document.createElement("button");
copyButton.className = "imageGalleryNav-copyButton btn btn-secondary";
copyButton.innerText = "Copy";
copyButton.onclick = (event) => {
event.preventDefault();
handleCopyClick();
}

var pasteButton = document.createElement("button");
pasteButton.className = "imageGalleryNav-pasteButton btn btn-secondary";
pasteButton.innerText = "Paste";
pasteButton.onclick = (event) => {
event.preventDefault();
handlePasteClick();
}

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);
}
// 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;

// Copy Button click handler
const copyClickHandler = (event) => {
event.preventDefault();
handleCopyClick(propsRef.values);
};

// Paste Button click handler
const pasteClickHandler = (event) => {
event.preventDefault();
handlePasteClick(propsRef.onSelect, propsRef.values);
};

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",
);

const copyEventHandler = (e) => {
e.preventDefault();
copyButtonRef.current.click();
};

const pasteEventHandler = (e) => {
e.preventDefault();
pasteButtonRef.current.click();
};

if (tagInputBox) {
tagInputBox.addEventListener("copy", copyEventHandler);
tagInputBox.addEventListener("paste", pasteEventHandler);
}
}
}, []);

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 = [];

Expand All @@ -108,84 +140,54 @@
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" },
OR: { aliases: { value: tagName, modifier: "EQUALS" } },
};
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();
})();
4 changes: 2 additions & 2 deletions plugins/tagCopyPaste/tagCopyPaste.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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:
Expand Down