From 721eaa5d07ac04ce94c1c16e0b03fac9180d7290 Mon Sep 17 00:00:00 2001 From: DebaucheryLibrarian Date: Wed, 17 Jun 2026 00:05:42 +0200 Subject: [PATCH] Added actors admin panel with bulk merge. Fixed merge failing if source and target actor have conflicting network profiles. --- assets/css/inputs.css | 6 +- assets/img/icons/cancel.svg | 4 + assets/img/icons/checkbox-checked.svg | 5 + assets/img/icons/checkbox-checked2.svg | 4 + assets/img/icons/checkbox-partial.svg | 4 + assets/img/icons/checkbox-partial2.svg | 4 + assets/img/icons/checkbox-unchecked.svg | 4 + assets/img/icons/checkbox-unchecked2.svg | 4 + assets/img/icons/collaboration.svg | 12 + assets/img/icons/exclude.svg | 4 + assets/img/icons/interset.svg | 4 + assets/img/icons/merge.svg | 5 + assets/img/icons/popout.svg | 4 + assets/img/icons/stack-plus.svg | 4 + assets/img/icons/stack.svg | 4 + assets/img/icons/stack2.svg | 4 + assets/img/icons/stack3.svg | 4 + assets/img/icons/unite.svg | 4 + assets/img/icons/unlink5.svg | 11 + components/actors/bio.vue | 2 +- components/actors/merge.vue | 71 +++++- components/actors/tile.vue | 12 + components/admin/admin.vue | 8 + pages/admin/+Page.vue | 7 + pages/admin/actors/+Page.vue | 305 +++++++++++++++++++++++ pages/admin/actors/+onBeforeRender.js | 19 ++ src/actors.js | 77 +++--- src/web/actors.js | 4 +- 28 files changed, 548 insertions(+), 52 deletions(-) create mode 100755 assets/img/icons/cancel.svg create mode 100755 assets/img/icons/checkbox-checked.svg create mode 100755 assets/img/icons/checkbox-checked2.svg create mode 100755 assets/img/icons/checkbox-partial.svg create mode 100755 assets/img/icons/checkbox-partial2.svg create mode 100755 assets/img/icons/checkbox-unchecked.svg create mode 100755 assets/img/icons/checkbox-unchecked2.svg create mode 100755 assets/img/icons/collaboration.svg create mode 100755 assets/img/icons/exclude.svg create mode 100755 assets/img/icons/interset.svg create mode 100755 assets/img/icons/merge.svg create mode 100755 assets/img/icons/popout.svg create mode 100755 assets/img/icons/stack-plus.svg create mode 100755 assets/img/icons/stack.svg create mode 100755 assets/img/icons/stack2.svg create mode 100755 assets/img/icons/stack3.svg create mode 100755 assets/img/icons/unite.svg create mode 100755 assets/img/icons/unlink5.svg create mode 100644 pages/admin/actors/+Page.vue create mode 100644 pages/admin/actors/+onBeforeRender.js diff --git a/assets/css/inputs.css b/assets/css/inputs.css index bf83556..0eb5573 100644 --- a/assets/css/inputs.css +++ b/assets/css/inputs.css @@ -42,7 +42,7 @@ fill: var(--glass); } - &:hover { + &:hover:not(:disabled) { cursor: pointer; background: var(--primary); color: var(--text-light); @@ -55,6 +55,10 @@ &:focus { outline: none; } + + &:disabled { + background: var(--glass-weak-30); + } } .button-label { diff --git a/assets/img/icons/cancel.svg b/assets/img/icons/cancel.svg new file mode 100755 index 0000000..dbf440b --- /dev/null +++ b/assets/img/icons/cancel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/checkbox-checked.svg b/assets/img/icons/checkbox-checked.svg new file mode 100755 index 0000000..1375fb6 --- /dev/null +++ b/assets/img/icons/checkbox-checked.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/img/icons/checkbox-checked2.svg b/assets/img/icons/checkbox-checked2.svg new file mode 100755 index 0000000..1df05a8 --- /dev/null +++ b/assets/img/icons/checkbox-checked2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/checkbox-partial.svg b/assets/img/icons/checkbox-partial.svg new file mode 100755 index 0000000..760dfa0 --- /dev/null +++ b/assets/img/icons/checkbox-partial.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/checkbox-partial2.svg b/assets/img/icons/checkbox-partial2.svg new file mode 100755 index 0000000..2d3fd04 --- /dev/null +++ b/assets/img/icons/checkbox-partial2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/checkbox-unchecked.svg b/assets/img/icons/checkbox-unchecked.svg new file mode 100755 index 0000000..731e074 --- /dev/null +++ b/assets/img/icons/checkbox-unchecked.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/checkbox-unchecked2.svg b/assets/img/icons/checkbox-unchecked2.svg new file mode 100755 index 0000000..84606ac --- /dev/null +++ b/assets/img/icons/checkbox-unchecked2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/collaboration.svg b/assets/img/icons/collaboration.svg new file mode 100755 index 0000000..ab6db47 --- /dev/null +++ b/assets/img/icons/collaboration.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/img/icons/exclude.svg b/assets/img/icons/exclude.svg new file mode 100755 index 0000000..e6578f3 --- /dev/null +++ b/assets/img/icons/exclude.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/interset.svg b/assets/img/icons/interset.svg new file mode 100755 index 0000000..49e5b20 --- /dev/null +++ b/assets/img/icons/interset.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/merge.svg b/assets/img/icons/merge.svg new file mode 100755 index 0000000..93b2e2c --- /dev/null +++ b/assets/img/icons/merge.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/img/icons/popout.svg b/assets/img/icons/popout.svg new file mode 100755 index 0000000..555c0b4 --- /dev/null +++ b/assets/img/icons/popout.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/stack-plus.svg b/assets/img/icons/stack-plus.svg new file mode 100755 index 0000000..d6bc1b3 --- /dev/null +++ b/assets/img/icons/stack-plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/stack.svg b/assets/img/icons/stack.svg new file mode 100755 index 0000000..2a0e859 --- /dev/null +++ b/assets/img/icons/stack.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/stack2.svg b/assets/img/icons/stack2.svg new file mode 100755 index 0000000..e621c85 --- /dev/null +++ b/assets/img/icons/stack2.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/stack3.svg b/assets/img/icons/stack3.svg new file mode 100755 index 0000000..a6f10fd --- /dev/null +++ b/assets/img/icons/stack3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/unite.svg b/assets/img/icons/unite.svg new file mode 100755 index 0000000..3b52a14 --- /dev/null +++ b/assets/img/icons/unite.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/img/icons/unlink5.svg b/assets/img/icons/unlink5.svg new file mode 100755 index 0000000..d9332f6 --- /dev/null +++ b/assets/img/icons/unlink5.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/components/actors/bio.vue b/components/actors/bio.vue index 6e0b0d3..5752447 100644 --- a/components/actors/bio.vue +++ b/components/actors/bio.vue @@ -384,7 +384,7 @@ diff --git a/components/actors/merge.vue b/components/actors/merge.vue index 5db0ab0..bcaa9b4 100644 --- a/components/actors/merge.vue +++ b/components/actors/merge.vue @@ -1,6 +1,6 @@ + + diff --git a/pages/admin/actors/+onBeforeRender.js b/pages/admin/actors/+onBeforeRender.js new file mode 100644 index 0000000..ae2e493 --- /dev/null +++ b/pages/admin/actors/+onBeforeRender.js @@ -0,0 +1,19 @@ +import { fetchActors } from '#/src/actors.js'; + +export default async function onBeforeRender(pageContext) { + const { actors } = await fetchActors({ + query: pageContext.urlParsed.search.q, + }, { + limit: 100, + // order: pageContext.urlParsed.search.order?.split('.') || ['likes', 'desc'], + }, pageContext.user); + + return { + pageContext: { + title: 'Actors', + pageProps: { + actors, + }, + }, + }; +} diff --git a/src/actors.js b/src/actors.js index 2e93447..e3471f1 100644 --- a/src/actors.js +++ b/src/actors.js @@ -378,7 +378,7 @@ async function queryManticoreSql(filters, options, _reqUser) { if (filters.query.charAt(0) === '#') { builder.where('id', Number(escape(filters.query.slice(1)))); } else { - builder.whereRaw('match(\'@(name,aliases) :query:\', actors)', { query: escape(filters.query) }); + builder.whereRaw(`match('@(name,aliases) :query:${filters.query.charAt(0) === '=' ? '' : '*'}', actors)`, { query: escape(filters.query) }); } } @@ -447,8 +447,13 @@ async function queryManticoreSql(filters, options, _reqUser) { builder.where('has_avatar', 1); } + console.log('ACTOR OPTIONS', options); + if (options.order?.[0] === 'name') { - builder.orderBy('actors.slug', options.order[1]); + builder.orderBy([ + { column: 'actors.slug', order: options.order[1] }, + { column: 'actors.entity_id', order: 'asc' }, + ]); } else if (options.order?.[0] === 'likes') { builder.orderBy([ { column: 'actors.stashed', order: options.order[1] }, @@ -477,6 +482,10 @@ async function queryManticoreSql(filters, options, _reqUser) { ]); } else { builder.orderBy('actors.slug', 'asc'); + builder.orderBy([ + { column: 'actors.slug', order: 'asc' }, + { column: 'actors.entity_id', order: 'asc' }, + ]); } }) .limit(options.limit) @@ -558,72 +567,70 @@ export async function createActor(newActor, context, reqUser) { return curateActor(actorEntry); } -export async function mergeActors(targetActorId, sourceActorId, reqUser) { +export async function mergeActors(targetActorId, sourceActorIds, reqUser) { if (!verifyAbility(reqUser, 'actor', 'merge')) { throw new HttpError('You are not permitted to merge actors', 403); } - const [targetActor, sourceActor] = await Promise.all([ + if (sourceActorIds.includes(targetActorId)) { + throw new HttpError('Cannot merge actor profile into itself', 400); + } + + const [targetActor, sourceActors] = await Promise.all([ knex('actors') .where('id', targetActorId) .whereNull('entity_id') .whereNull('alias_for') .first(), knex('actors') - .where('id', sourceActorId) - .first(), + .whereIn('id', sourceActorIds), ]); if (!targetActor) { throw new HttpError('Target actor not found', 404); } - if (!sourceActor) { + if (sourceActors.length < sourceActorIds.length) { throw new HttpError('Source actor not found', 404); } - if (targetActor.entity_id) { - throw new HttpError('Target actor is not global', 400); - } - - if (targetActor.alias_for) { - throw new HttpError('Target actor is aliased', 400); - } - const trx = await knex.transaction(); let mergedProfiles; let mergedScenes; try { - await trx('actors') - .update('alias_for', targetActorId) - .where('id', sourceActorId) - .returning(['id', 'alias_for']); + const [existingProfiles] = await Promise.all([ + trx('actors_profiles') + .where('actor_id', targetActorId), + trx('actors') + .update('alias_for', targetActorId) + .whereIn('id', sourceActorIds) + .returning(['id', 'alias_for']), + // some avatars are not matched to a profile, need to investigate why this happens and the avatar table needs a dedicated actor field + trx('actors_avatars') + .update('actor_id', targetActorId) + .whereIn('actor_id', sourceActorIds), + trx('stashes_actors') + .update('actor_id', targetActorId) + .whereIn('actor_id', sourceActorIds) + .returning('id'), + ]); mergedProfiles = await trx('actors_profiles') .update('actor_id', targetActorId) - .where('actor_id', sourceActorId) + .whereIn('actor_id', sourceActorIds) + .whereNotIn('entity_id', existingProfiles.map((profile) => profile.entity_id)) .returning('id'); - // some avatars are not matched to a profile, need to investigate why this happens and the avatar table needs a dedicated actor field - await trx('actors_avatars') - .update('actor_id', targetActorId) - .where('actor_id', sourceActorId); - mergedScenes = await trx('releases_actors') .update({ actor_id: targetActorId, - alias_id: sourceActorId, + alias_id: knex.raw('actor_id'), }) - .where('actor_id', sourceActorId) + .whereIn('actor_id', sourceActorIds) .returning('release_id'); - await trx('stashes_actors') - .update('actor_id', targetActorId) - .where('actor_id', sourceActorId) - .returning('id'); - await trx.commit(); } catch (error) { await trx.rollback(); @@ -631,7 +638,7 @@ export async function mergeActors(targetActorId, sourceActorId, reqUser) { throw error; } - await interpolateProfiles([targetActorId, sourceActorId], { + await interpolateProfiles([targetActorId, ...sourceActorIds], { knex, logger, moment, @@ -641,8 +648,8 @@ export async function mergeActors(targetActorId, sourceActorId, reqUser) { await Promise.all([ syncScenes(mergedScenes.map((scene) => scene.release_id)), - syncActors([targetActorId, sourceActorId]), - syncStashes('actor', [targetActorId, sourceActorId]), + syncActors([targetActorId, ...sourceActorIds]), + syncStashes('actor', [targetActorId, ...sourceActorIds]), ]); return { diff --git a/src/web/actors.js b/src/web/actors.js index 3497d3b..09e89b4 100644 --- a/src/web/actors.js +++ b/src/web/actors.js @@ -180,7 +180,7 @@ export async function createActorApi(req, res) { } export async function mergeActorsApi(req, res) { - const result = await mergeActors(Number(req.params.targetActorId), Number(req.params.sourceActorId), req.user); + const result = await mergeActors(Number(req.params.targetActorId), req.params.sourceActorIds.split(',').map((actorId) => Number(actorId)), req.user); res.send(result); } @@ -208,7 +208,7 @@ export const actorsRouter = Router(); actorsRouter.get('/api/actors', fetchActorsApi); actorsRouter.post('/api/actors', createActorApi); -actorsRouter.post('/api/actors/:targetActorId/merge/:sourceActorId', mergeActorsApi); +actorsRouter.post('/api/actors/:targetActorId/merge/:sourceActorIds', mergeActorsApi); actorsRouter.get('/api/revisions/actors', fetchActorRevisionsApi); actorsRouter.get('/api/revisions/actors/:revisionId', fetchActorRevisionsApi);