Blame Raw
Cohee · 51ad27fb · · 410 lines (16.5 KB)
2 contributors
1import { event_types, eventSource, saveSettingsDebounced } from '../../../script.js';
2import { deleteAttachment, getDataBankAttachments, getDataBankAttachmentsForSource, getFileAttachment, uploadFileAttachmentToServer } from '../../chats.js';
3import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
4import { SlashCommand } from '../../slash-commands/SlashCommand.js';
5import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
6import { SlashCommandClosure } from '../../slash-commands/SlashCommandClosure.js';
7import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
8import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
9import { SlashCommandExecutor } from '../../slash-commands/SlashCommandExecutor.js';
10import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
11
12/**
13 * List of attachment sources
14 * @type {string[]}
15 */
16const TYPES = ['global', 'character', 'chat'];
17const FIELDS = ['name', 'url'];
18
19/**
20 * Get attachments from the data bank. Includes disabled attachments.
21 * @param {string} [source] Source for the attachments
22 * @returns {import('../../chats').FileAttachment[]} List of attachments
23 */
24function getAttachments(source) {
25 if (!source || !TYPES.includes(source)) {
26 return getDataBankAttachments(true);
27 }
28
29 return getDataBankAttachmentsForSource(source, true);
30}
31
32/**
33 * Get attachment by a single name or URL.
34 * @param {import('../../chats').FileAttachment[]} attachments List of attachments
35 * @param {string} value Name or URL of the attachment
36 * @returns {import('../../chats').FileAttachment} Attachment
37 */
38function getAttachmentByField(attachments, value) {
39 const match = (a) => String(a).trim().toLowerCase() === String(value).trim().toLowerCase();
40 const fullMatchByURL = attachments.find(it => match(it.url));
41 const fullMatchByName = attachments.find(it => match(it.name));
42 return fullMatchByURL || fullMatchByName;
43}
44
45/**
46 * Get attachment by multiple fields.
47 * @param {import('../../chats').FileAttachment[]} attachments List of attachments
48 * @param {string[]} values Name and URL of the attachment to search for
49 * @returns
50 */
51function getAttachmentByFields(attachments, values) {
52 for (const value of values) {
53 const attachment = getAttachmentByField(attachments, value);
54 if (attachment) {
55 return attachment;
56 }
57 }
58
59 return null;
60}
61
62/**
63 * Callback for listing attachments in the data bank.
64 * @param {object} args Named arguments
65 * @returns {string} JSON string of the list of attachments
66 */
67function listDataBankAttachments(args) {
68 const attachments = getAttachments(args?.source);
69 const field = args?.field;
70 return JSON.stringify(attachments.map(a => FIELDS.includes(field) ? a[field] : a.url));
71}
72
73/**
74 * Callback for getting text from an attachment in the data bank.
75 * @param {object} args Named arguments
76 * @param {string} value Name or URL of the attachment
77 * @returns {Promise<string>} Content of the attachment
78 */
79async function getDataBankText(args, value) {
80 if (!value) {
81 toastr.warning('No attachment name or URL provided.');
82 return;
83 }
84
85 const attachments = getAttachments(args?.source);
86 const attachment = getAttachmentByField(attachments, value);
87
88 if (!attachment) {
89 toastr.warning('Attachment not found.');
90 return;
91 }
92
93 const content = await getFileAttachment(attachment.url);
94 return content;
95}
96
97/**
98 * Callback for adding an attachment to the data bank.
99 * @param {object} args Named arguments
100 * @param {string} value Content of the attachment
101 * @returns {Promise<string>} URL of the attachment
102 */
103async function uploadDataBankAttachment(args, value) {
104 const source = args?.source && TYPES.includes(args.source) ? args.source : 'chat';
105 const name = args?.name || new Date().toLocaleString();
106 const file = new File([value], name, { type: 'text/plain' });
107 const url = await uploadFileAttachmentToServer(file, source);
108 return url;
109}
110
111/**
112 * Callback for updating an attachment in the data bank.
113 * @param {object} args Named arguments
114 * @param {string} value Content of the attachment
115 * @returns {Promise<string>} URL of the attachment
116 */
117async function updateDataBankAttachment(args, value) {
118 const source = args?.source && TYPES.includes(args.source) ? args.source : 'chat';
119 const attachments = getAttachments(source);
120 const attachment = getAttachmentByFields(attachments, [args?.url, args?.name]);
121
122 if (!attachment) {
123 toastr.warning('Attachment not found.');
124 return '';
125 }
126
127 await deleteAttachment(attachment, source, () => { }, false);
128 const file = new File([value], attachment.name, { type: 'text/plain' });
129 const url = await uploadFileAttachmentToServer(file, source);
130 return url;
131}
132
133/**
134 * Callback for deleting an attachment from the data bank.
135 * @param {object} args Named arguments
136 * @param {string} value Name or URL of the attachment
137 * @returns {Promise<string>} Empty string
138 */
139async function deleteDataBankAttachment(args, value) {
140 const source = args?.source && TYPES.includes(args.source) ? args.source : 'chat';
141 const attachments = getAttachments(source);
142 const attachment = getAttachmentByField(attachments, value);
143
144 if (!attachment) {
145 toastr.warning('Attachment not found.');
146 return '';
147 }
148
149 await deleteAttachment(attachment, source, () => { }, false);
150 return '';
151}
152
153/**
154 * Callback for disabling an attachment in the data bank.
155 * @param {object} args Named arguments
156 * @param {string} value Name or URL of the attachment
157 * @returns {Promise<string>} Empty string
158 */
159async function disableDataBankAttachment(args, value) {
160 const attachments = getAttachments(args?.source);
161 const attachment = getAttachmentByField(attachments, value);
162
163 if (!attachment) {
164 toastr.warning('Attachment not found.');
165 return '';
166 }
167
168 if (extension_settings.disabled_attachments.includes(attachment.url)) {
169 return '';
170 }
171
172 extension_settings.disabled_attachments.push(attachment.url);
173 return '';
174}
175
176/**
177 * Callback for enabling an attachment in the data bank.
178 * @param {object} args Named arguments
179 * @param {string} value Name or URL of the attachment
180 * @returns {Promise<string>} Empty string
181 */
182async function enableDataBankAttachment(args, value) {
183 const attachments = getAttachments(args?.source);
184 const attachment = getAttachmentByField(attachments, value);
185
186 if (!attachment) {
187 toastr.warning('Attachment not found.');
188 return '';
189 }
190
191 const index = extension_settings.disabled_attachments.indexOf(attachment.url);
192 if (index === -1) {
193 return '';
194 }
195
196 extension_settings.disabled_attachments.splice(index, 1);
197 return '';
198}
199
200function cleanUpAttachments() {
201 let shouldSaveSettings = false;
202 if (extension_settings.character_attachments) {
203 Object.values(extension_settings.character_attachments).flat().filter(a => a.text).forEach(a => {
204 shouldSaveSettings = true;
205 delete a.text;
206 });
207 }
208 if (Array.isArray(extension_settings.attachments)) {
209 extension_settings.attachments.filter(a => a.text).forEach(a => {
210 shouldSaveSettings = true;
211 delete a.text;
212 });
213 }
214 if (shouldSaveSettings) {
215 saveSettingsDebounced();
216 }
217}
218
219/**
220 * Clean up character attachments when a character is deleted.
221 * @param {{character: Character}} data Event data
222 */
223function cleanUpCharacterAttachments(data) {
224 const avatar = data?.character?.avatar;
225 if (!avatar) return;
226 if (Array.isArray(extension_settings?.character_attachments?.[avatar])) {
227 delete extension_settings.character_attachments[avatar];
228 saveSettingsDebounced();
229 }
230}
231
232/**
233 * Handle character rename event to update character attachments.
234 * @param {string} oldAvatar Old avatar name
235 * @param {string} newAvatar New avatar name
236 */
237function handleCharacterRename(oldAvatar, newAvatar) {
238 if (!oldAvatar || !newAvatar) return;
239 if (Array.isArray(extension_settings?.character_attachments?.[oldAvatar])) {
240 extension_settings.character_attachments[newAvatar] = extension_settings.character_attachments[oldAvatar];
241 delete extension_settings.character_attachments[oldAvatar];
242 saveSettingsDebounced();
243 }
244}
245
246export async function init() {
247 eventSource.on(event_types.APP_READY, cleanUpAttachments);
248 eventSource.on(event_types.CHARACTER_DELETED, cleanUpCharacterAttachments);
249 eventSource.on(event_types.CHARACTER_RENAMED, handleCharacterRename);
250 const manageButton = await renderExtensionTemplateAsync('attachments', 'manage-button', {});
251 const attachButton = await renderExtensionTemplateAsync('attachments', 'attach-button', {});
252 $('#data_bank_wand_container').append(manageButton);
253 $('#attach_file_wand_container').append(attachButton);
254
255 /** A collection of local enum providers for this context of data bank */
256 const localEnumProviders = {
257 /**
258 * All attachments in the data bank based on the source argument. If not provided, defaults to 'chat'.
259 * @param {'name' | 'url'} returnField - Whether the enum should return the 'name' field or the 'url'
260 * @param {'chat' | 'character' | 'global' | ''} fallbackSource - The source to use if the source argument is not provided. Empty string to use all sources.
261 * */
262 attachments: (returnField = 'name', fallbackSource = 'chat') => (/** @type {SlashCommandExecutor} */ executor) => {
263 const source = executor.namedArgumentList.find(it => it.name == 'source')?.value ?? fallbackSource;
264 if (source instanceof SlashCommandClosure) throw new Error('Argument \'source\' does not support closures');
265 const attachments = getAttachments(source);
266
267 return attachments.map(attachment => new SlashCommandEnumValue(
268 returnField === 'name' ? attachment.name : attachment.url,
269 `${enumIcons.getStateIcon(!extension_settings.disabled_attachments.includes(attachment.url))} [${source}] ${returnField === 'url' ? attachment.name : attachment.url}`,
270 enumTypes.enum, enumIcons.file));
271 },
272 };
273
274 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
275 name: 'db',
276 callback: () => {
277 document.getElementById('manageAttachments')?.click();
278 return '';
279 },
280 aliases: ['databank', 'data-bank'],
281 helpString: 'Open the data bank',
282 }));
283
284 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
285 name: 'db-list',
286 callback: listDataBankAttachments,
287 aliases: ['databank-list', 'data-bank-list'],
288 helpString: 'List attachments in the Data Bank as a JSON-serialized array. Optionally, provide the source of the attachments and the field to list by.',
289 namedArgumentList: [
290 new SlashCommandNamedArgument('source', 'The source of the attachments.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
291 new SlashCommandNamedArgument('field', 'The field to list by.', ARGUMENT_TYPE.STRING, false, false, 'url', FIELDS),
292 ],
293 returns: ARGUMENT_TYPE.LIST,
294 }));
295
296 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
297 name: 'db-get',
298 callback: getDataBankText,
299 aliases: ['databank-get', 'data-bank-get'],
300 helpString: 'Get attachment text from the Data Bank. Either provide the name or URL of the attachment. Optionally, provide the source of the attachment.',
301 namedArgumentList: [
302 new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
303 ],
304 unnamedArgumentList: [
305 SlashCommandArgument.fromProps({
306 description: 'The name or URL of the attachment.',
307 typeList: [ARGUMENT_TYPE.STRING],
308 isRequired: true,
309 acceptsMultiple: false,
310 enumProvider: localEnumProviders.attachments('name', ''),
311 }),
312 ],
313 returns: ARGUMENT_TYPE.STRING,
314 }));
315
316 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
317 name: 'db-add',
318 callback: uploadDataBankAttachment,
319 aliases: ['databank-add', 'data-bank-add'],
320 helpString: 'Add an attachment to the Data Bank. If name is not provided, it will be generated automatically. Returns the URL of the attachment.',
321 namedArgumentList: [
322 new SlashCommandNamedArgument('source', 'The source for the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
323 new SlashCommandNamedArgument('name', 'The name of the attachment.', ARGUMENT_TYPE.STRING, false, false),
324 ],
325 unnamedArgumentList: [
326 new SlashCommandArgument('The content of the file attachment.', ARGUMENT_TYPE.STRING, true, false),
327 ],
328 returns: ARGUMENT_TYPE.STRING,
329 }));
330
331 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
332 name: 'db-update',
333 callback: updateDataBankAttachment,
334 aliases: ['databank-update', 'data-bank-update'],
335 helpString: 'Update an attachment in the Data Bank, preserving its name. Returns a new URL of the attachment.',
336 namedArgumentList: [
337 new SlashCommandNamedArgument('source', 'The source for the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
338 SlashCommandNamedArgument.fromProps({
339 name: 'name',
340 description: 'The name of the attachment.',
341 typeList: [ARGUMENT_TYPE.STRING],
342 enumProvider: localEnumProviders.attachments('name'),
343 }),
344 SlashCommandNamedArgument.fromProps({
345 name: 'url',
346 description: 'The URL of the attachment.',
347 typeList: [ARGUMENT_TYPE.STRING],
348 enumProvider: localEnumProviders.attachments('url'),
349 }),
350 ],
351 unnamedArgumentList: [
352 new SlashCommandArgument('The content of the file attachment.', ARGUMENT_TYPE.STRING, true, false),
353 ],
354 returns: ARGUMENT_TYPE.STRING,
355 }));
356
357 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
358 name: 'db-disable',
359 callback: disableDataBankAttachment,
360 aliases: ['databank-disable', 'data-bank-disable'],
361 helpString: 'Disable an attachment in the Data Bank by its name or URL. Optionally, provide the source of the attachment.',
362 namedArgumentList: [
363 new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
364 ],
365 unnamedArgumentList: [
366 SlashCommandArgument.fromProps({
367 description: 'The name or URL of the attachment.',
368 typeList: [ARGUMENT_TYPE.STRING],
369 isRequired: true,
370 enumProvider: localEnumProviders.attachments('name', ''),
371 }),
372 ],
373 }));
374
375 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
376 name: 'db-enable',
377 callback: enableDataBankAttachment,
378 aliases: ['databank-enable', 'data-bank-enable'],
379 helpString: 'Enable an attachment in the Data Bank by its name or URL. Optionally, provide the source of the attachment.',
380 namedArgumentList: [
381 new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, '', TYPES),
382 ],
383 unnamedArgumentList: [
384 SlashCommandArgument.fromProps({
385 description: 'The name or URL of the attachment.',
386 typeList: [ARGUMENT_TYPE.STRING],
387 isRequired: true,
388 enumProvider: localEnumProviders.attachments('name', ''),
389 }),
390 ],
391 }));
392
393 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
394 name: 'db-delete',
395 callback: deleteDataBankAttachment,
396 aliases: ['databank-delete', 'data-bank-delete'],
397 helpString: 'Delete an attachment from the Data Bank.',
398 namedArgumentList: [
399 new SlashCommandNamedArgument('source', 'The source of the attachment.', ARGUMENT_TYPE.STRING, false, false, 'chat', TYPES),
400 ],
401 unnamedArgumentList: [
402 SlashCommandArgument.fromProps({
403 description: 'The name or URL of the attachment.',
404 typeList: [ARGUMENT_TYPE.STRING],
405 isRequired: true,
406 enumProvider: localEnumProviders.attachments(),
407 }),
408 ],
409 }));
410}