Skip to content

Commit 0587f0d

Browse files
[tagCopyPaste] Refactor to use PluginApi.patch. (#672)
1 parent d183fc3 commit 0587f0d

4 files changed

Lines changed: 137 additions & 133 deletions

File tree

plugins/tagCopyPaste/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
https://discourse.stashapp.cc/t/tagcopypaste/1858
44

5-
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.
5+
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.
66

77
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.
88

@@ -14,5 +14,5 @@ Pasting will check your current clipboard for a comma and/or newline delimited s
1414

1515
## Config Options:
1616
- **Create If Not Exists**: If enabled, new tags will be created when pasted list contains entries that do not already exist. DEFAULT: Disabled
17-
- **Require Confirmation**: If enabled, user needs to confirm paste before changes are saved. DEFAULT: Disabled
17+
- **Require Confirmation**: If enabled, user needs to confirm new tags being created. DEFAULT: Disabled
1818

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
button.imageGalleryNav-copyButton,
2-
button.imageGalleryNav-pasteButton {
3-
float: right;
4-
height: 21px;
5-
line-height: 20px;
6-
padding: 0 10px;
7-
margin-right: 15px;
1+
div.multi-set {
2+
position: relative;
83
}
4+
5+
div.modal-content div.tagCopyPaste div.btn-group {
6+
position: absolute;
7+
top:0;
8+
right:0;
9+
}
10+
Lines changed: 124 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,103 +1,135 @@
11
(async () => {
2+
const PluginApi = window.PluginApi;
3+
const React = PluginApi.React;
4+
25
let pluginSettings = {};
36
const defaultPluginSettings = {
47
createIfNotExists: false,
58
requireConfirmation: false,
69
};
710

8-
var objID = null;
9-
var objType = null;
10-
11-
// helper function to get the innerText of all elements matching a selector
12-
const getAllInnerText = (selector) => Array.from(document.querySelectorAll(selector))
13-
.map((el) => el.innerText.trim())
14-
.filter((text) => text !== "");
15-
16-
// On image page, get data about gallery (image's position within gallery, next/prev image IDs),
17-
// add arrow buttons to page, and register arrow keypress handlers,
18-
async function setupTagCopyPaste(objTypeTriggered) {
11+
// Helper functions for handling array of tags.
12+
const getTagNameArray = (tagArray) => tagArray.map((value) => value.name);
13+
const getTagNameString = (tagArray) => getTagNameArray(tagArray).join(", ");
14+
const sortTagArray = (tagArray) =>
15+
tagArray.sort((a, b) => {
16+
var aCompStr = a.sort_name ? a.sort_name : a.name;
17+
var bCompStr = b.sort_name ? b.sort_name : b.name;
18+
return aCompStr.localeCompare(bCompStr);
19+
});
20+
21+
async function setupTagCopyPaste() {
1922
// Get plugin settings.
2023
const configSettings = await csLib.getConfiguration("tagCopyPaste", {}); // getConfiguration is from cs-ui-lib.js
2124
pluginSettings = {
2225
...defaultPluginSettings,
2326
...configSettings,
2427
};
2528

26-
objID = window.location.pathname.split("/")[2];
27-
objType = objTypeTriggered;
28-
29-
// Add UI elements.
30-
if (objID !== "new") {
31-
insertCopyPasteButtons();
32-
}
33-
}
34-
35-
function copyEventHandler(event) {
36-
event.preventDefault();
37-
handleCopyClick();
38-
}
39-
40-
function pasteEventHandler(event) {
41-
event.preventDefault();
42-
handlePasteClick();
43-
}
44-
45-
function insertCopyPasteButtons() {
46-
// listen for copy and paste events within tag input box
47-
// find tag input box
48-
const tagInputBox = document.querySelector("label[for='tag_ids'] + div .react-select__value-container");
49-
if (tagInputBox) {
50-
tagInputBox.removeEventListener("copy", copyEventHandler);
51-
tagInputBox.removeEventListener("paste", pasteEventHandler);
52-
tagInputBox.addEventListener("copy", copyEventHandler);
53-
tagInputBox.addEventListener("paste", pasteEventHandler);
54-
}
55-
56-
var copyButton = document.createElement("button");
57-
copyButton.className = "imageGalleryNav-copyButton btn btn-secondary";
58-
copyButton.innerText = "Copy";
59-
copyButton.onclick = (event) => {
60-
event.preventDefault();
61-
handleCopyClick();
62-
}
63-
64-
var pasteButton = document.createElement("button");
65-
pasteButton.className = "imageGalleryNav-pasteButton btn btn-secondary";
66-
pasteButton.innerText = "Paste";
67-
pasteButton.onclick = (event) => {
68-
event.preventDefault();
69-
handlePasteClick();
70-
}
71-
72-
if (document.querySelector("button.imageGalleryNav-pasteButton") == null) {
73-
document.querySelector("label[for='tag_ids']").append(pasteButton);
74-
}
75-
if (document.querySelector("button.imageGalleryNav-copyButton") == null) {
76-
document.querySelector("label[for='tag_ids']").append(copyButton);
77-
}
29+
// Patch TagSelect to add copy/paste buttons.
30+
PluginApi.patch.after("TagSelect", function (props, _, originalComponent) {
31+
const copyButtonRef = React.useRef(null);
32+
const pasteButtonRef = React.useRef(null);
33+
const propsRef = props;
34+
35+
// Copy Button click handler
36+
const copyClickHandler = (event) => {
37+
event.preventDefault();
38+
handleCopyClick(propsRef.values);
39+
};
40+
41+
// Paste Button click handler
42+
const pasteClickHandler = (event) => {
43+
event.preventDefault();
44+
handlePasteClick(propsRef.onSelect, propsRef.values);
45+
};
46+
47+
React.useEffect(() => {
48+
// Not the ideal way to handle this, but it works.
49+
// Wait for the buttons to render and then add the onCopy/onPaste handlers to select control DOM element.
50+
if (copyButtonRef && copyButtonRef.current) {
51+
var mainCopyPasteWrapper =
52+
copyButtonRef.current.parentElement.parentElement;
53+
var tagInputBox = mainCopyPasteWrapper.querySelector(
54+
".react-select__value-container",
55+
);
56+
57+
const copyEventHandler = (e) => {
58+
e.preventDefault();
59+
copyButtonRef.current.click();
60+
};
61+
62+
const pasteEventHandler = (e) => {
63+
e.preventDefault();
64+
pasteButtonRef.current.click();
65+
};
66+
67+
if (tagInputBox) {
68+
tagInputBox.addEventListener("copy", copyEventHandler);
69+
tagInputBox.addEventListener("paste", pasteEventHandler);
70+
}
71+
}
72+
}, []);
73+
74+
return React.createElement("div", { className: "tagCopyPaste" }, [
75+
React.createElement(
76+
"div",
77+
{
78+
className: "btn-group",
79+
},
80+
[
81+
React.createElement(
82+
"button",
83+
{
84+
type: "button",
85+
ref: copyButtonRef,
86+
onClick: copyClickHandler,
87+
className:
88+
"imageGalleryNav-copyButton btn btn-secondary btn-sm",
89+
},
90+
"Copy",
91+
),
92+
React.createElement(
93+
"button",
94+
{
95+
type: "button",
96+
ref: pasteButtonRef,
97+
onClick: pasteClickHandler,
98+
className:
99+
"imageGalleryNav-pasteButton btn btn-secondary btn-sm",
100+
},
101+
"Paste",
102+
),
103+
],
104+
),
105+
originalComponent,
106+
]);
107+
});
78108
}
79109

80110
// Handle copy click. Return delimited list of current tags.
81-
async function handleCopyClick() {
111+
async function handleCopyClick(propValues) {
82112
// Get tags from input box
83113
// join as comma delimited list
84-
const tagList = getAllInnerText("label[for='tag_ids'] + div .react-select__multi-value__label").join(",")
85-
// write to clipboard.
114+
const tagList = getTagNameString(propValues);
86115
navigator.clipboard.writeText(tagList);
87116
}
88117

89118
// Handle paste click.
90-
async function handlePasteClick() {
119+
async function handlePasteClick(onSelect, propValues) {
91120
// Parse tag list from comma delimited string.
92121
const tagInput = await navigator.clipboard.readText();
93-
var inputTagList = tagInput.split(/\r?\n|\r|,/).map(s => s.trim()).filter((text) => text !== "") // do de-duplication later
122+
var inputTagList = tagInput
123+
.split(/\r?\n|\r|,/)
124+
.map((s) => s.trim())
125+
.filter((text) => text !== ""); // do de-duplication later
94126

95127
// Get tags from input box and also add to tag list.
96-
const existingTagList = getAllInnerText("label[for='tag_ids'] + div .react-select__multi-value__label");
128+
const existingTagList = getTagNameArray(propValues);
97129

98130
inputTagList = [...new Set([...inputTagList, ...existingTagList])].sort();
99131

100-
var missingTags = [];
132+
var missingTagNames = [];
101133
var existingTags = [];
102134
var tagUpdateList = [];
103135

@@ -108,84 +140,54 @@
108140
existingTags.push(inputTag);
109141
tagUpdateList.push(tagID[0]);
110142
} else {
111-
missingTags.push(inputTag);
143+
missingTagNames.push(inputTag);
112144
}
113145
}
114146

115-
if (pluginSettings.requireConfirmation) {
116-
const missingTagsStr = missingTags.join(", ");
117-
const existingTagsStr = existingTags.join(", ");
118-
const msg = pluginSettings.createIfNotExists
119-
? `Missing Tags that will be created:\n${missingTagsStr}\n\nExisting Tags that will be saved: \n${existingTagsStr}\n\nContinue?`
120-
: `Missing Tags that will be skipped:\n${missingTagsStr}\n\nExisting Tags that will be saved: \n${existingTagsStr}\n\nContinue?`;
121-
122-
if (!confirm(msg)) {
123-
return;
147+
// Create missing tags if enabled. Prompt user to confirm if confirmation option is also enabled.
148+
const missingTagsStr = missingTagNames.join(", ");
149+
const msg = `Missing Tags that will be created:\n${missingTagsStr}\n\nContinue?`;
150+
if (
151+
pluginSettings.createIfNotExists &&
152+
missingTagNames.length &&
153+
(!pluginSettings.requireConfirmation || confirm(msg))
154+
) {
155+
for (const missingTagName of missingTagNames) {
156+
const newTag = await createNewTag(missingTagName);
157+
if (newTag != null) tagUpdateList.push(newTag);
124158
}
125159
}
126160

127-
if (pluginSettings.createIfNotExists && missingTags.length) {
128-
for (const missingTag of missingTags) {
129-
const newTagID = await createNewTag(missingTag);
130-
if (newTagID != null) tagUpdateList.push(newTagID);
131-
}
132-
}
133-
134-
// Update tags on object with new tag ID list.
135-
await updateObjTags(
136-
tagUpdateList,
137-
`${objType.toLowerCase()}Update`,
138-
`${objType}UpdateInput`
139-
);
140-
141-
window.location.reload();
161+
// Update TagSelect control with new tag list.
162+
onSelect(sortTagArray(tagUpdateList));
142163
}
143164

144165
// *** GQL Calls ***
145166

146-
// Update Object by ID, new tags list, and GQL mutation name.
147-
async function updateObjTags(tags, fnName, inputName) {
148-
const variables = { input: { id: objID, tag_ids: tags } };
149-
const query = `mutation UpdateObj($input:${inputName}!) { ${fnName}(input: $input) {id} }`;
150-
return await csLib.callGQL({ query, variables });
151-
}
152-
153-
// Update Object by ID, new tags list, and GQL mutation name.
167+
// Create new tag.
168+
// Return newly created tag object.
154169
async function createNewTag(tagName) {
155170
const variables = { input: { name: tagName } };
156-
const query = `mutation CreateTag($input:TagCreateInput!) { tagCreate(input: $input) {id} }`;
171+
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 } } }`;
157172
return await csLib
158173
.callGQL({ query, variables })
159-
.then((data) => data.tagCreate.id);
174+
.then((data) => data.tagCreate);
160175
}
161176

162177
// Find Tag by name/alias.
163-
// Return match tag ID.
178+
// Return matched list of tag objects.
164179
async function getTagByName(tagName) {
165180
const tagFilter = {
166181
name: { value: tagName, modifier: "EQUALS" },
167182
OR: { aliases: { value: tagName, modifier: "EQUALS" } },
168183
};
169184
const findFilter = { per_page: -1, sort: "name" };
170185
const variables = { tag_filter: tagFilter, filter: findFilter };
171-
const query = `query ($tag_filter: TagFilterType!, $filter: FindFilterType!) { findTags(filter: $filter, tag_filter: $tag_filter) { tags { id } } }`;
186+
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 } } } }`;
172187
return await csLib
173188
.callGQL({ query, variables })
174-
.then((data) => data.findTags.tags.map((item) => item.id));
189+
.then((data) => data.findTags.tags);
175190
}
176191

177-
// listener arrays
178-
[
179-
[ "/scenes/", "[id*='-edit-details']", "Scene" ],
180-
[ "/studios/", "[id='studio-edit']", "Studio" ],
181-
[ "/groups/", "[id='group-edit']", "Group" ],
182-
[ "/performers/", "[id='performer-edit']", "Performer" ],
183-
[ "/galleries/", "[id*='-edit-details']", "Gallery" ],
184-
[ "/images/", "[id*='-edit-details']", "Image" ]
185-
].forEach(([path, selector, objTypeTriggered]) => {
186-
// Wait for the page to load and the element to be present.
187-
csLib.PathElementListener(path, selector, () => {
188-
setupTagCopyPaste(objTypeTriggered);
189-
}); // PathElementListener is from cs-ui-lib.js
190-
});
192+
setupTagCopyPaste();
191193
})();

plugins/tagCopyPaste/tagCopyPaste.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: tagCopyPaste
22
# requires: CommunityScriptsUILibrary
33
description: Adds Copy/Paste buttons to Tags field.
4-
version: 0.4
4+
version: 0.5
55
url: https://discourse.stashapp.cc/t/tagcopypaste/1858
66
settings:
77
createIfNotExists:
@@ -10,7 +10,7 @@ settings:
1010
type: BOOLEAN
1111
requireConfirmation:
1212
displayName: Require Confirmation
13-
description: If enabled, user needs to confirm paste before changes are saved.
13+
description: If enabled, user needs to confirm new tags being created.
1414
type: BOOLEAN
1515
ui:
1616
requires:

0 commit comments

Comments
 (0)