Adding revisions for actor merges.

This commit is contained in:
2026-07-13 03:40:08 +02:00
parent 4031f35c73
commit e95145ff1a
2 changed files with 278 additions and 207 deletions

View File

@@ -128,6 +128,14 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
};
}
if (delta.key === 'mergeTarget' || delta.key === 'mergeSource') {
// new socials don't have IDs yet, so we need to compare the values
return {
...delta,
value: `#${delta.actorId} ${delta.actorName} (${delta.profileIds.length} profiles${delta.profileIds.length > 0 ? `: ${delta.profileIds.join()}` : ''})`,
};
}
if (dateKeys.includes(delta.key) && delta.value) {
return {
...delta,

View File

@@ -597,6 +597,205 @@ function curateActorEntry(actor, context) {
};
}
function getBaseActor(actor) {
return Object.fromEntries(Object.entries(actor).map(([key, values]) => {
if ([
'scenes',
'likes',
'stashes',
'profiles',
].includes(key)) {
return null;
}
if ([
'socials',
].includes(key)) {
return [key, values];
}
if (values?.id) {
return [key, values.id];
}
if (values?.metric) {
return [key, values.metric];
}
if (Array.isArray(values)) {
return [key, values.map((value) => value?.hash || value?.id || value)];
}
return [key, values];
}).filter(Boolean));
}
function getDeltas(edits, baseActor, options) {
return Promise.all(Object.entries(edits).map(async ([key, value]) => {
if (baseActor[key] === value || typeof value === 'undefined') {
return null;
}
if (key === 'originCountry' && edits.originPlace) {
// place overrides country
return null;
}
if (key === 'residenceCountry' && edits.residencePlace) {
// place overrides country
return null;
}
if (['originPlace', 'residencePlace'].includes(key)) {
if (!value && !baseActor[key]) {
// don't pollute deltas if value is already unset
return null;
}
if (!value) {
return [
// { key: key.includes('origin') ? 'originCountry' : 'residenceCountry', value: null },
{ key: key.includes('origin') ? 'originState' : 'residenceState', value: null },
{ key: key.includes('origin') ? 'originCity' : 'residenceCity', value: null },
];
}
const resolvedLocation = await resolvePlace(value, {
knex,
redis,
logger,
slugify,
unprint,
}, {
userAgent: 'contact via https://traxxx.me/',
});
if (!resolvedLocation) {
throw new Error(`Failed to resolve ${key} ${value}`);
}
const countryKey = key.includes('origin') ? 'originCountry' : 'residenceCountry';
if (!resolvedLocation.country) {
return null;
}
return [
{
key: countryKey,
value: resolvedLocation.country,
comment: edits[countryKey] && edits[countryKey] !== resolvedLocation.country
? `${countryKey} overridden by resolved ${key}`
: null,
},
{
key: key.includes('origin') ? 'originState' : 'residenceState',
value: resolvedLocation.state || null, // explicitly unset to prevent outcomes like Los Angeles, Greenland
},
{
key: key.includes('origin') ? 'originCity' : 'residenceCity',
value: resolvedLocation.city || null,
},
];
}
if (key === 'socials') {
const convertedSocials = curateSocials(value);
const convertedUrls = value
.filter((social) => social.url && !convertedSocials.some((convertedSocial) => convertedSocial.url === social.url))
.map((social) => social.url);
const conversionComment = convertedUrls.length > 0
? `curated URLs ${convertedUrls.join(', ')} as social handles`
: null;
return {
key,
value: convertedSocials,
comment: conversionComment,
};
}
if (['cup', 'bust', 'waist', 'hip'].includes(key)) {
const convertedValue = convertFigure(key, value, options.figureUnits);
const conversionComment = !value || convertedValue === value
? null
: `${key} converted from ${value} ${options.figureUnits?.toUpperCase() || 'US'} to ${convertedValue} US`;
return {
key,
value: convertedValue,
comment: conversionComment,
};
}
if (['height'].includes(key)) {
const convertedValue = convertHeight(value, options.sizeUnits);
if (baseActor[key] === convertedValue) {
return null;
}
const conversionComment = !value || convertedValue === value
? null
: `${key} converted from ${value[0]} in ${value[1]} ft to ${convertedValue} cm`;
return {
key,
value: convertedValue,
comment: conversionComment,
};
}
if (['weight'].includes(key)) {
const convertedValue = convertWeight(value, options.sizeUnits);
if (baseActor[key] === convertedValue) {
return null;
}
const conversionComment = !value || convertedValue === value
? null
: `${key} converted from ${value} lbs to ${convertedValue} kg`;
return {
key,
value: convertedValue,
comment: conversionComment,
};
}
if (['penisLength', 'penisGirth'].includes(key) && options.penisUnits === 'imperial') {
const convertedValue = Math.round(convert(value, 'inches').to('cm'));
if (baseActor[key] === convertedValue) {
return null;
}
return {
key,
value: convertedValue,
comment: `${key} converted from ${value} inches to ${convertedValue} cm`,
};
}
if (Array.isArray(value)) {
const valueSet = new Set(value);
const baseSet = new Set(baseActor[key]);
if (valueSet.size === baseSet.size && baseActor[key].every((id) => valueSet.has(id))) {
return null;
}
return { key, value: Array.from(valueSet) };
}
return { key, value };
})).then((rawDeltas) => rawDeltas.flat().filter(Boolean));
}
export async function createActor(newActor, context, reqUser) {
if (!reqUser || reqUser.role === 'user') {
throw new HttpError('You are not permitted to create actors', 403);
@@ -619,7 +818,7 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
throw new HttpError('Cannot merge actor profile into itself', 400);
}
const [targetActor, sourceActors] = await Promise.all([
const [targetActorEntry, sourceActorEntries] = await Promise.all([
knex('actors')
.where('id', targetActorId)
.whereNull('entity_id')
@@ -629,14 +828,27 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
.whereIn('id', sourceActorIds),
]);
if (!targetActor) {
if (!targetActorEntry) {
throw new HttpError('Target actor not found', 404);
}
if (sourceActors.length < sourceActorIds.length) {
if (sourceActorEntries.length < sourceActorIds.length) {
throw new HttpError('Source actor not found', 404);
}
const [[targetActor], sourceActors] = await Promise.all([
fetchActorsById([targetActorEntry.id], {
reqUser,
includeAssets: true,
includePartOf: true,
}),
fetchActorsById(sourceActorEntries.map((sourceActorEntry) => sourceActorEntry.id), {
reqUser,
includeAssets: true,
includePartOf: true,
}),
]);
const trx = await knex.transaction();
let mergedProfiles = [];
@@ -674,7 +886,7 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
.whereIn('actor_id', sourceActorIds)
.whereNotIn('entity_id', existingProfiles.map((profile) => profile.entity_id))
.whereNotIn('id', duplicateSourceProfiles.map((profile) => profile.id))
.returning('id');
.returning(['id', 'actor_id']);
// find releases that have more than one source actor assigned
duplicateSourceActors = await trx('releases_actors')
@@ -748,11 +960,61 @@ export async function mergeActors(targetActorId, sourceActorIds, reqUser) {
.whereIn('actor_id', sourceActorIds)
.returning('stash_id');
/* TODO: add revision entries
console.log('SOURCES', targetActor, sourceActors);
const baseTargetActor = getBaseActor(targetActor);
throw new Error('ABORT');
*/
const targetDeltas = sourceActors.map((sourceActor) => ({
key: 'mergeSource',
actorId: sourceActor.id,
actorName: sourceActor.name,
profileIds: sourceProfiles
.filter((sourceProfile) => sourceProfile.actor_id === sourceActor.id)
.map((sourceProfile) => sourceProfile.id),
}));
await trx('actors_revisions')
.insert({
actor_id: targetActor.id,
user_id: reqUser.id,
base: baseTargetActor,
deltas: JSON.stringify(targetDeltas),
hash: mj.hash({
base: baseTargetActor,
deltas: targetDeltas,
}),
reviewed_by: reqUser.id,
reviewed_at: knex.fn.now(),
applied_at: knex.fn.now(),
approved: true,
});
await trx('actors_revisions')
.insert(sourceActors.map((sourceActor) => {
const baseSourceActor = getBaseActor(sourceActor);
const deltas = [{
key: 'mergeTarget',
actorId: targetActorId,
actorName: targetActor.name,
profileIds: sourceProfiles
.filter((sourceProfile) => sourceProfile.actor_id === sourceActor.id)
.map((sourceProfile) => sourceProfile.id),
}];
return {
actor_id: sourceActor.id,
user_id: reqUser.id,
base: baseSourceActor,
deltas: JSON.stringify(deltas),
hash: mj.hash({
base: baseSourceActor,
deltas: targetDeltas,
}),
reviewed_by: reqUser.id,
reviewed_at: knex.fn.now(),
applied_at: knex.fn.now(),
approved: true,
};
}));
await trx.commit();
}
@@ -1151,205 +1413,6 @@ function curateSocials(socials) {
}).filter(Boolean);
}
function getBaseActor(actor) {
return Object.fromEntries(Object.entries(actor).map(([key, values]) => {
if ([
'scenes',
'likes',
'stashes',
'profiles',
].includes(key)) {
return null;
}
if ([
'socials',
].includes(key)) {
return [key, values];
}
if (values?.id) {
return [key, values.id];
}
if (values?.metric) {
return [key, values.metric];
}
if (Array.isArray(values)) {
return [key, values.map((value) => value?.hash || value?.id || value)];
}
return [key, values];
}).filter(Boolean));
}
function getDeltas(edits, baseActor, options) {
return Promise.all(Object.entries(edits).map(async ([key, value]) => {
if (baseActor[key] === value || typeof value === 'undefined') {
return null;
}
if (key === 'originCountry' && edits.originPlace) {
// place overrides country
return null;
}
if (key === 'residenceCountry' && edits.residencePlace) {
// place overrides country
return null;
}
if (['originPlace', 'residencePlace'].includes(key)) {
if (!value && !baseActor[key]) {
// don't pollute deltas if value is already unset
return null;
}
if (!value) {
return [
// { key: key.includes('origin') ? 'originCountry' : 'residenceCountry', value: null },
{ key: key.includes('origin') ? 'originState' : 'residenceState', value: null },
{ key: key.includes('origin') ? 'originCity' : 'residenceCity', value: null },
];
}
const resolvedLocation = await resolvePlace(value, {
knex,
redis,
logger,
slugify,
unprint,
}, {
userAgent: 'contact via https://traxxx.me/',
});
if (!resolvedLocation) {
throw new Error(`Failed to resolve ${key} ${value}`);
}
const countryKey = key.includes('origin') ? 'originCountry' : 'residenceCountry';
if (!resolvedLocation.country) {
return null;
}
return [
{
key: countryKey,
value: resolvedLocation.country,
comment: edits[countryKey] && edits[countryKey] !== resolvedLocation.country
? `${countryKey} overridden by resolved ${key}`
: null,
},
{
key: key.includes('origin') ? 'originState' : 'residenceState',
value: resolvedLocation.state || null, // explicitly unset to prevent outcomes like Los Angeles, Greenland
},
{
key: key.includes('origin') ? 'originCity' : 'residenceCity',
value: resolvedLocation.city || null,
},
];
}
if (key === 'socials') {
const convertedSocials = curateSocials(value);
const convertedUrls = value
.filter((social) => social.url && !convertedSocials.some((convertedSocial) => convertedSocial.url === social.url))
.map((social) => social.url);
const conversionComment = convertedUrls.length > 0
? `curated URLs ${convertedUrls.join(', ')} as social handles`
: null;
return {
key,
value: convertedSocials,
comment: conversionComment,
};
}
if (['cup', 'bust', 'waist', 'hip'].includes(key)) {
const convertedValue = convertFigure(key, value, options.figureUnits);
const conversionComment = !value || convertedValue === value
? null
: `${key} converted from ${value} ${options.figureUnits?.toUpperCase() || 'US'} to ${convertedValue} US`;
return {
key,
value: convertedValue,
comment: conversionComment,
};
}
if (['height'].includes(key)) {
const convertedValue = convertHeight(value, options.sizeUnits);
if (baseActor[key] === convertedValue) {
return null;
}
const conversionComment = !value || convertedValue === value
? null
: `${key} converted from ${value[0]} in ${value[1]} ft to ${convertedValue} cm`;
return {
key,
value: convertedValue,
comment: conversionComment,
};
}
if (['weight'].includes(key)) {
const convertedValue = convertWeight(value, options.sizeUnits);
if (baseActor[key] === convertedValue) {
return null;
}
const conversionComment = !value || convertedValue === value
? null
: `${key} converted from ${value} lbs to ${convertedValue} kg`;
return {
key,
value: convertedValue,
comment: conversionComment,
};
}
if (['penisLength', 'penisGirth'].includes(key) && options.penisUnits === 'imperial') {
const convertedValue = Math.round(convert(value, 'inches').to('cm'));
if (baseActor[key] === convertedValue) {
return null;
}
return {
key,
value: convertedValue,
comment: `${key} converted from ${value} inches to ${convertedValue} cm`,
};
}
if (Array.isArray(value)) {
const valueSet = new Set(value);
const baseSet = new Set(baseActor[key]);
if (valueSet.size === baseSet.size && baseActor[key].every((id) => valueSet.has(id))) {
return null;
}
return { key, value: Array.from(valueSet) };
}
return { key, value };
})).then((rawDeltas) => rawDeltas.flat().filter(Boolean));
}
export async function createActorRevision(actorId, {
edits,
comment,