diff --git a/components/edit/revisions.vue b/components/edit/revisions.vue index ba3d3c2..7b1553e 100644 --- a/components/edit/revisions.vue +++ b/components/edit/revisions.vue @@ -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, diff --git a/src/actors.js b/src/actors.js index 095711a..b125ba3 100644 --- a/src/actors.js +++ b/src/actors.js @@ -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,