Added actor profile revisions.

This commit is contained in:
DebaucheryLibrarian 2024-10-22 03:12:42 +02:00
parent b5bef49f73
commit 3967745fb3
31 changed files with 1907 additions and 67 deletions

3
.gitmodules vendored
View File

@ -2,3 +2,6 @@
path = static
ignore = all
url = git@unknown.name:DebaucheryLibrarian/traxxx-assets.git
[submodule "common"]
path = common
url = git@unknown.name:DebaucheryLibrarian/traxxx-common.git

1
common Submodule

@ -0,0 +1 @@
Subproject commit 40011a62dae9da8deda71e9f8daf39665a8b7958

View File

@ -136,13 +136,32 @@
class="bio-item figure"
>
<dfn class="bio-label"><Icon icon="ruler" />Figure</dfn>
<span class="bio-value">{{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }}</span>
</li>
<li
v-if="!actor.naturalBoobs || !actor.naturalButt"
class="bio-item augmentations"
>
<dfn class="bio-label"><Icon icon="magic-wand2" />Augmentations</dfn>
<span class="bio-value">
<Icon
v-if="actor.naturalBoobs === false"
:title="'Enhanced boobs'"
icon="magic-wand2"
class="enhanced"
/>{{ actor.bust || '??' }}{{ actor.cup || '?' }}-{{ actor.waist || '??' }}-{{ actor.hip || '??' }}
<div
v-if="!actor.naturalBoobs"
:title="[actor.boobsVolume, augmentationMap[actor.boobsPlacement] || actor.boobsPlacement, augmentationMap[actor.boobsImplant] || actor.boobsImplant].filter(Boolean).join(' ')"
class="augmentations-section"
>Boobs<template v-if="actor.boobsVolume || actor.boobsImplant">:&nbsp;</template>
<template v-if="actor.boobsVolume">{{ actor.boobsVolume }}cc</template>
<template v-if="actor.boobsImplant">&nbsp;{{ augmentationMap[actor.boobsImplant] || actor.boobsImplant }}</template>
</div>
<div
v-if="!actor.naturalButt"
class="augmentations-section"
>Butt<template v-if="actor.buttVolume || actor.buttImplant">:&nbsp;</template>
<template v-if="actor.buttVolume">{{ actor.buttVolume }}cc</template>
<template v-if="actor.buttImplant">&nbsp;{{ augmentationMap[actor.buttImplant] || actor.buttImplant }}</template>
</div>
</span>
</li>
@ -240,6 +259,21 @@
</li>
<li class="bio-item updated hideable">Updated {{ formatDate(actor.updatedAt, 'yyyy-MM-dd hh:mm') }}, ID: {{ actor.id }}</li>
<li class="bio-item actor-actions">
<a
v-if="user && user.role !== 'user'"
:href="`/actor/edit/${actor.id}/${actor.slug}`"
target="_blank"
class="link"
>Edit bio</a>
<a
:href="`/actor/revisions/${actor.id}/${actor.slug}`"
target="_blank"
class="link"
>Revisions</a>
</li>
</ul>
<div class="descriptions-container">
@ -294,13 +328,16 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, inject } from 'vue';
import getPath from '#/src/get-path.js';
import { formatDate } from '#/utils/format.js';
const expanded = ref(false);
const pageContext = inject('pageContext');
const user = pageContext.user;
const props = defineProps({
actor: {
type: Object,
@ -326,6 +363,17 @@ const showExpand = [
'weight',
].some((attribute) => !!props.actor[attribute]);
const augmentationMap = {
bbl: 'BBL',
fat: 'fat transfer',
lift: 'direct lift',
lipo: 'lipo without BBL',
filler: 'filler',
mms: 'MMS',
over: 'over-muscle',
under: 'under-muscle',
};
const descriptions = Object.values(Object.fromEntries(props.actor.profiles
.filter((profile) => !!profile.description)
.map((profile) => [profile.descriptionHash, {
@ -519,6 +567,11 @@ const descriptions = Object.values(Object.fromEntries(props.actor.profiles
content: ',\00a0';
}
.augmentations .bio-value {
flex-direction: column;
align-items: flex-end;
}
.updated {
color: var(--highlight-weak-20);
font-size: .8rem;
@ -638,6 +691,16 @@ const descriptions = Object.values(Object.fromEntries(props.actor.profiles
}
}
.actor-actions {
justify-content: flex-start;
gap: 1rem;
font-weight: normal;
.link {
color: var(--highlight-strong-20);
}
}
@media(--big) {
.descriptions-container {
display: none;

View File

@ -4,10 +4,18 @@
<ul class="nav-items nolist">
<li class="nav-item">
<a
href="/admin/revisions"
href="/admin/revisions/scenes"
class="nav-link nolink"
:class="{ active: pageContext.routeParams.section === 'revisions' }"
>Revisions</a>
:class="{ active: pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'scenes' }"
>Scene Revisions</a>
</li>
<li class="nav-item">
<a
href="/admin/revisions/actors"
class="nav-link nolink"
:class="{ active: pageContext.routeParams.section === 'revisions' && pageContext.routeParams.domain === 'actors' }"
>Actor Revisions</a>
</li>
</ul>
</nav>
@ -22,8 +30,6 @@
import { inject } from 'vue';
const pageContext = inject('pageContext');
// console.log(pageContext);
</script>
<style scoped>
@ -36,7 +42,14 @@ const pageContext = inject('pageContext');
.nav {
display: flex;
padding: 1rem 1rem 0 1rem;
padding: 1rem 1rem .75rem 1rem;
border-bottom: solid 1px var(--shadow-weak-30);
margin-bottom: .25rem;
}
.nav-items {
display: flex;
gap: .5rem;
}
.nav-item {

View File

@ -27,16 +27,16 @@
<template v-if="context === 'admin' || expanded.has(rev.id)">
<div class="rev-header">
<a
:href="`/scene/${rev.sceneId}`"
:href="`/${domain.slice(0, -1)}/${rev.sceneId || rev.actorId}/${rev.base.slug}`"
target="_blank"
class="rev-link rev-scene nolink noshrink"
>{{ rev.sceneId }}@{{ rev.hash.slice(0, 6) }}</a>
>{{ rev.sceneId || rev.actorId }}@{{ rev.hash.slice(0, 6) }}</a>
<a
:href="`/scene/${rev.sceneId}`"
:href="`/${domain.slice(0, -1)}/${rev.sceneId || rev.actorId}/${rev.base.slug}`"
target="_blank"
class="rev-link rev-title nolink ellipsis"
>{{ rev.base.title }}</a>
>{{ rev.base.title || rev.base.name }}</a>
<div class="rev-details noshrink">
<a
@ -219,6 +219,7 @@ defineProps({
const pageContext = inject('pageContext');
const revisions = ref(pageContext.pageProps.revisions);
const domain = pageContext.routeParams.domain;
const actors = ref(pageContext.pageProps.actors);
const tags = ref(pageContext.pageProps.tags);
@ -241,6 +242,8 @@ const mappedKeys = {
const dateKeys = [
'date',
'dateOfBirth',
'dateOfDeath',
'productionDate',
'createdAt',
];
@ -307,7 +310,7 @@ const curatedRevisions = computed(() => revisions.value.map((revision) => {
}));
async function reloadRevisions() {
const updatedRevisions = await get('/revisions', {
const updatedRevisions = await get(`/revisions/${domain}`, {
isFinalized: showReviewed.value ? undefined : false,
limit: 50,
});
@ -322,12 +325,12 @@ async function reviewRevision(revision, isApproved) {
reviewedRevisions.value.add(revision.id);
try {
await post(`/revisions/${revision.id}/reviews`, {
await post(`/revisions/${domain}/${revision.id}/reviews`, {
isApproved,
feedback: feedbacks.value[revision.id],
});
const updatedRevision = await get(`/revisions/${revision.id}`, {
const updatedRevision = await get(`/revisions/${domain}/${revision.id}`, {
revisionId: revision.id,
});

1
package-lock.json generated
View File

@ -45,6 +45,7 @@
"mathjs": "^12.2.1",
"merkle-json": "^2.6.0",
"mitt": "^3.0.1",
"moment": "^2.30.1",
"mysql": "^2.18.1",
"nanoid": "^5.0.4",
"object.omit": "^3.0.0",

View File

@ -45,6 +45,7 @@
"mathjs": "^12.2.1",
"merkle-json": "^2.6.0",
"mitt": "^3.0.1",
"moment": "^2.30.1",
"mysql": "^2.18.1",
"nanoid": "^5.0.4",
"object.omit": "^3.0.0",

View File

@ -1,10 +1,11 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
import { redirect, render } from 'vike/abort'; /* eslint-disable-line import/extensions */
import { fetchActorsById } from '#/src/actors.js';
import { fetchScenes } from '#/src/scenes.js';
import { fetchMovies } from '#/src/movies.js';
import { curateScenesQuery } from '#/src/web/scenes.js';
import { curateMoviesQuery } from '#/src/web/movies.js';
import { fetchCountries } from '#/src/countries.js';
async function fetchReleases(pageContext) {
if (pageContext.routeParams.domain === 'movies') {
@ -33,9 +34,16 @@ async function fetchReleases(pageContext) {
}
export async function onBeforeRender(pageContext) {
const [[actor], actorReleases] = await Promise.all([
const isEditing = pageContext._pageId === '/pages/actors/@actorId/edit';
if (isEditing && !pageContext.user) {
throw redirect(`/login?r=${encodeURIComponent(pageContext.urlOriginal)}`);
}
const [[actor], actorReleases, countries] = await Promise.all([
fetchActorsById([Number(pageContext.routeParams.actorId)], {}, pageContext.user),
fetchReleases(pageContext),
isEditing && fetchCountries(),
]);
if (!actor) {
@ -44,9 +52,12 @@ export async function onBeforeRender(pageContext) {
return {
pageContext: {
title: actor.name,
title: isEditing
? `Editing '${actor.name}'`
: actor.name,
pageProps: {
actor,
countries,
...actorReleases,
},
},

View File

@ -1,7 +1,7 @@
import { match } from 'path-to-regexp';
// import { resolveRoute } from 'vike/routing'; // eslint-disable-line import/extensions
const path = '/actor/:actorId/:actorSlug?/:domain(scenes|movies)?/:scope?/:page?';
const path = '/actor/:actorId(\\d+)/:actorSlug?/:domain(scenes|movies)?/:scope?/:page?';
const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => {

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
export default '/actor/edit/@actorId/*';

View File

@ -0,0 +1,62 @@
<template>
<div class="content">
<div class="revs-header">
<h2 class="heading">Revisions for "{{ scene.title }}"</h2>
<div class="revs-actions">
<a
:href="`/scene/edit/${scene.id}/${scene.slug}`"
class="link"
>Edit scene</a>
<a
:href="`/scene/${scene.id}/${scene.slug}`"
target="_blank"
class="link"
>Go to scene</a>
</div>
</div>
<Revisions context="scene" />
</div>
</template>
<script setup>
import { inject } from 'vue';
import Revisions from '#/components/edit/revisions.vue';
const pageContext = inject('pageContext');
const scene = pageContext.pageProps.scene;
</script>
<style scoped>
.content {
padding: 1rem;
flex-grow: 1;
}
.revs-header {
display: flex;
justify-content: space-between;
align-items: center;
.heading {
line-height: 1.5;
}
}
.revs-actions {
display: flex;
gap: 2rem;
flex-shrink: 0;
}
@media(--compact) {
.revs-header {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
}
}
</style>

View File

@ -0,0 +1,24 @@
import { fetchActorsById } from '#/src/actors.js';
import { fetchSceneRevisions } from '#/src/scenes.js';
export async function onBeforeRender(pageContext) {
const [actor] = await fetchActorsById([Number(pageContext.routeParams.actorId)], {}, pageContext.user);
const {
revisions,
} = await fetchSceneRevisions(null, {
sceneId: actor.id,
isFinalized: true,
limit: 100,
}, pageContext.user);
return {
pageContext: {
title: `Revisions for '${actor.name}'`,
pageProps: {
actor,
revisions,
},
},
};
}

View File

@ -0,0 +1 @@
export default '/actor/revisions/@actorId/*';

View File

@ -1 +0,0 @@
export default '/admin/@section/*';

View File

@ -0,0 +1,24 @@
import { render } from 'vike/abort'; /* eslint-disable-line import/extensions */
import { fetchActorRevisions } from '#/src/actors.js';
export async function onBeforeRender(pageContext) {
if (!pageContext.user || pageContext.user.role === 'user') {
throw render(404);
}
const {
revisions,
} = await fetchActorRevisions(null, {
isFinalized: false,
limit: 50,
}, pageContext.user);
return {
pageContext: {
title: pageContext.routeParams.section,
pageProps: {
revisions,
},
},
};
}

View File

@ -0,0 +1,19 @@
import { match } from 'path-to-regexp';
const path = '/admin/:section/:domain(actors)';
const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => {
const matched = urlMatch(pageContext.urlPathname);
if (matched) {
return {
routeParams: {
section: matched.params.section,
domain: matched.params.domain,
},
};
}
return false;
};

View File

@ -0,0 +1,19 @@
import { match } from 'path-to-regexp';
const path = '/admin/:section/:domain(scenes)';
const urlMatch = match(path, { decode: decodeURIComponent });
export default (pageContext) => {
const matched = urlMatch(pageContext.urlPathname);
if (matched) {
return {
routeParams: {
section: matched.params.section,
domain: matched.params.domain,
},
};
}
return false;
};

View File

@ -36,8 +36,11 @@
>
<img
v-if="network.hasLogo"
:src="network.type === 'network' || network.isIndependent || !network.parent ? `/logos/${network.slug}/thumbs/network.png` : `/logos/${network.parent.slug}/thumbs/${network.slug}.png`"
:src="network.type === 'network' || network.isIndependent || !network.parent
? `/logos/${network.slug}/thumbs/network.png`
: `/logos/${network.parent.slug}/thumbs/${network.slug}.png`"
:alt="network.name"
loading="lazy"
class="logo"
>
@ -58,7 +61,7 @@
</template>
<script setup>
import { ref, inject } from 'vue';
import { ref, inject, onMounted } from 'vue';
import navigate from '#/src/navigate.js';
@ -120,6 +123,12 @@ const sections = [
async function search() {
navigate('/channels', { q: query.value || undefined }, { redirect: true });
}
onMounted(() => {
window.addEventListener('load', (event) => {
console.log(event);
});
});
</script>
<style scoped>
@ -194,6 +203,9 @@ async function search() {
height: 100%;
width: 100%;
object-fit: contain;
background-repeat: no-repeat;
background-size: contain;
background-position: center;
}
@media(--small-30) {

View File

@ -676,7 +676,9 @@ function copySummary() {
.templates {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: .25rem 0;
margin-top: .5rem;
.icon {

View File

@ -38,7 +38,7 @@
<li v-if="user.role !== 'user'">
<a
href="/admin/revisions"
href="/admin/revisions/scenes"
class="link"
>Go to revisions admin</a>
</li>
@ -50,7 +50,7 @@
@submit.prevent
>
<div class="editor-header">
<h2 class="heading ellipsis">Edit scene #{{ scene.id }}</h2>
<h2 class="heading ellipsis">Edit scene #{{ scene.id }} - {{ scene.title }}</h2>
<a
:href="`/scene/${scene.id}/${scene.slug}`"

View File

@ -1,7 +1,11 @@
import config from 'config';
import { differenceInYears } from 'date-fns';
import { unit } from 'mathjs';
import { MerkleJson } from 'merkle-json';
import moment from 'moment';
import omit from 'object.omit';
import initLogger from './logger.js';
import { knexOwner as knex, knexManticore } from './knex.js';
import { utilsApi } from './manticore.js';
import { HttpError } from './errors.js';
@ -11,6 +15,11 @@ import { curateMedia } from './media.js';
import { curateStash } from './stashes.js';
import escape from '../utils/escape-manticore.js';
import slugify from '../utils/slugify.js';
import { curateRevision } from './revisions.js';
import { interpolateProfiles } from '../common/actors.mjs'; // eslint-disable-line import/namespace
const logger = initLogger();
const mj = new MerkleJson();
export function curateActor(actor, context = {}) {
return {
@ -22,11 +31,22 @@ export function curateActor(actor, context = {}) {
dateOfBirth: actor.date_of_birth,
ageFromBirth: actor.date_of_birth && differenceInYears(Date.now(), actor.date_of_birth),
ageThen: context.sceneDate && actor.date_of_birth && differenceInYears(context.sceneDate, actor.date_of_birth),
dateOfDeath: actor.date_of_death,
bust: actor.bust,
cup: actor.cup,
waist: actor.waist,
hip: actor.hip,
naturalBoobs: actor.natural_boobs,
boobsVolume: actor.boobs_volume,
boobsImplant: actor.boobs_implant,
boobsPlacement: actor.boobs_placement,
boobsSurgeon: actor.boobs_surgeon,
naturalButt: actor.natural_butt,
buttVolume: actor.butt_volume,
buttImplant: actor.butt_implant,
penisLength: actor.penis_length,
penisGirth: actor.penis_girth,
isCircumcised: actor.is_circumcised,
height: actor.height && {
metric: actor.height,
imperial: unit(actor.height, 'cm').splitUnit(['ft', 'in']).map((value) => Math.round(value.toNumber())),
@ -139,6 +159,8 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
: [],
]);
console.log(actors);
if (options.order) {
return actors.map((actorEntry) => curateActor(actorEntry, {
stashes: stashes.filter((stash) => stash.actor_id === actorEntry.id),
@ -161,6 +183,8 @@ export async function fetchActorsById(actorIds, options = {}, reqUser) {
});
}).filter(Boolean);
console.log(curatedActors);
return curatedActors;
}
@ -377,3 +401,361 @@ export async function fetchActors(filters, rawOptions, reqUser) {
limit: options.limit,
};
}
export async function fetchActorRevisions(revisionId, filters = {}, reqUser) {
const limit = filters.limit || 50;
const page = filters.page || 1;
const revisions = await knex('actors_revisions')
.select(
'actors_revisions.*',
'users.username as username',
'reviewers.username as reviewer_username',
)
.leftJoin('users', 'users.id', 'actors_revisions.user_id')
.leftJoin('users as reviewers', 'reviewers.id', 'actors_revisions.reviewed_by')
.modify((builder) => {
if (!['admin', 'editor'].includes(reqUser?.role) && !filters.userId && !filters.actorId) {
builder.where('user_id', reqUser.id);
}
if (filters.userId) {
if (!['admin', 'editor'].includes(reqUser?.role) && filters.userId !== reqUser.id) {
throw new HttpError('You are not permitted to view revisions from other users.', 403);
}
builder.where('actors_revisions.user_id', filters.userId);
}
if (revisionId) {
builder.where('actors_revisions.id', revisionId);
return;
}
if (filters.actorId) {
builder.where('actors_revisions.actor_id', filters.actorId);
}
if (filters.isFinalized === false) {
builder.whereNull('approved');
}
if (filters.isFinalized === true) {
builder.whereNotNull('approved');
}
})
.orderBy('created_at', 'desc')
.limit(limit)
.offset((page - 1) * limit);
const curatedRevisions = revisions.map((revision) => curateRevision(revision));
return {
revisions: curatedRevisions,
revision: revisionId && curatedRevisions.find((revision) => revision.id === revisionId),
};
}
const keyMap = {
avatar: 'avatar_media_id',
dateOfBirth: 'date_of_birth',
dateOfDeath: 'date_of_death',
originCountry: 'birth_country_alpha2',
originState: 'birth_state',
originCity: 'birth_city',
residenceCountry: 'residence_country_alpha2',
residenceState: 'residence_state',
residenceCity: 'residence_city',
hairColor: 'hair_color',
naturalBoobs: 'natural_boobs',
boobsVolume: 'boobs_volume',
boobsImplant: 'boobs_implant',
boobsPlacement: 'boobs_placement',
boobsSurgeon: 'boobs_surgeon',
naturalButt: 'natural_butt',
buttVolume: 'butt_volume',
buttImplant: 'butt_implant',
penisLength: 'penis_length',
penisGirth: 'penis_girth',
isCircumcised: 'circumcised',
hasTattoos: 'has_tattoos',
hasPiercings: 'has_piercings',
};
async function applyActorValueDelta(profileId, delta, trx) {
console.log('value delta', profileId, delta, keyMap[delta.key], delta.value);
return knex('actors_profiles')
.where('id', profileId)
.update(keyMap[delta.key] || delta.key, delta.value)
.transacting(trx);
}
async function applyActorDirectDelta(actorId, delta, trx) {
console.log('value delta', delta);
return knex('actors')
.where('id', actorId)
.update(keyMap[delta.key] || delta.key, delta.value)
.modify((builder) => {
if (delta.key === 'name') {
builder.update('slug', slugify(delta.value));
}
})
.transacting(trx);
}
async function fetchMainProfile(actorId, wasCreated = false) {
const profileEntry = await knex('actors_profiles')
.where('actor_id', actorId)
.where('entity_id', null)
.first();
if (profileEntry) {
return profileEntry;
}
if (wasCreated) {
throw new HttpError('Failed to find or create main profile', 404);
}
await knex('actors_profiles').insert({
actor_id: actorId,
entity_id: null,
});
return fetchMainProfile(actorId, true);
}
/*
async function applyMainProfile(actorId) {
const [actorEntry, mainProfile] = await Promise.all([
knex('actors')
.where('id', actorId)
.first(),
fetchMainProfile(actorId),
]);
if (!actorEntry) {
throw new HttpError('No actor profile found to apply main profile to', 404);
}
const preservedKeys = ['id'];
// we start iterating from the actor entry so we don't include keys that are not yet supported by the actors table
const mergedProfile = Object.fromEntries(Object.entries(actorEntry)
.filter(([key]) => Object.hasOwn(mainProfile, key))
.map(([key, value]) => [key, mainProfile[key] === null || preservedKeys.includes(key)
? value
: mainProfile[key]]));
await knex('actors')
.where('id', actorId)
.update(mergedProfile);
}
*/
async function applyActorRevision(revisionIds, reqUser) {
const revisions = await knex('actors_revisions')
.whereIn('id', revisionIds)
.whereNull('applied_at'); // should not re-apply revision that was already applied
await revisions.reduce(async (chain, revision) => {
await chain;
const mainProfile = await fetchMainProfile(revision.actor_id);
await knex.transaction(async (trx) => {
await Promise.all(revision.deltas.map(async (delta) => {
if ([
'gender',
'avatar',
'dateOfBirth',
'dateOfDeath',
'originCountry',
'originState',
'originCity',
'residenceCountry',
'residenceState',
'residenceCity',
'height',
'weight',
'bust',
'cup',
'waist',
'hip',
'naturalBoobs',
'boobsVolume',
'boobsImplant',
'boobsPlacement',
'boobsSurgeon',
'naturalButt',
'buttVolume',
'buttImplant',
'penisLength',
'penisGirth',
'isCircumcised',
'hairColor',
'eyes',
'hasTattoos',
'tattoos',
'hasPiercings',
'piercings',
].includes(delta.key)) {
return applyActorValueDelta(mainProfile.id, delta, trx);
}
if (delta.key === 'name' && reqUser.role === 'admin') {
return applyActorDirectDelta(revision.actor_id, delta, trx);
}
return null;
}));
await knex('actors_revisions')
.transacting(trx)
.where('id', revision.id)
.update('applied_at', knex.fn.now());
// await trx.commit();
}).catch(async (error) => {
logger.error(`Failed to apply revision ${revision.id} on actor ${revision.actor_id}: ${error.message}`);
});
}, Promise.resolve());
const actorIds = Array.from(new Set(revisions.map((revision) => revision.actor_id)));
await interpolateProfiles(actorIds, {
knex,
logger,
moment,
slugify,
omit,
});
}
export async function reviewActorRevision(revisionId, isApproved, { feedback }, reqUser) {
if (!reqUser || reqUser.role === 'user') {
throw new HttpError('You are not permitted to approve revisions', 403);
}
if (typeof isApproved !== 'boolean') {
throw new HttpError('You must either approve or reject the revision', 400);
}
const updated = await knex('actors_revisions')
.where('id', revisionId)
.whereNull('approved') // don't rerun reviewed revision, must be forked into new revision instead
.whereNull('applied_at')
.update({
approved: isApproved,
reviewed_at: knex.fn.now(),
reviewed_by: reqUser.id,
feedback,
});
if (updated === 0) {
throw new HttpError('This revision was already reviewed', 409);
}
if (isApproved) {
await applyActorRevision([revisionId], reqUser);
}
}
export async function createActorRevision(actorId, { edits, comment, apply }, reqUser) {
const [
[actor],
openRevisions,
] = await Promise.all([
fetchActorsById([actorId], {
reqUser,
includeAssets: true,
includePartOf: true,
}),
knex('actors_revisions')
.where('user_id', reqUser.id)
.whereNull('approved'),
]);
if (!actor) {
throw new HttpError(`No actor with ID ${actorId} found to update`, 404);
}
if (openRevisions.length >= config.revisions.unapprovedLimit) {
throw new HttpError(`You have ${config.revisions.unapprovedLimit} unapproved revisions, please wait for approval before submitting another revision.`, 429);
}
const baseActor = Object.fromEntries(Object.entries(actor).map(([key, values]) => {
if ([
'scenes',
'likes',
'stashes',
'profiles',
].includes(key)) {
return null;
}
/* avatar should return id
if (values?.hash) {
return [key, values.hash];
}
*/
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));
const deltas = Object.entries(edits).map(([key, value]) => {
if (baseActor[key] === value) {
return null;
}
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: value || null };
}).filter(Boolean);
if (deltas.length === 0) {
throw new HttpError('No effective changes provided', 400);
}
const [revisionEntry] = await knex('actors_revisions')
.insert({
user_id: reqUser.id,
actor_id: actor.id,
base: JSON.stringify(baseActor),
deltas: JSON.stringify(deltas),
hash: mj.hash({
base: baseActor,
deltas,
}),
comment,
})
.returning('id');
if (['admin', 'editor'].includes(reqUser.role) && apply) {
// don't keep the editor waiting for the revision to apply
reviewActorRevision(revisionEntry.id, true, {}, reqUser);
}
}

View File

@ -28,3 +28,10 @@ export async function fetchCountriesByAlpha2(alpha2s, options = {}) {
return entries.map((countryEntry) => curateCountry(countryEntry));
}
export async function fetchCountries() {
const entries = await knex('countries')
.orderBy(knex.raw('coalesce(alias, name)'));
return entries.map((countryEntry) => curateCountry(countryEntry));
}

View File

@ -13,6 +13,7 @@ export function curateMedia(media, context = {}) {
width: media.width,
height: media.height,
index: media.index,
sharpness: media.sharpness,
credit: media.credit,
type: context.type || null,
};

24
src/revisions.js Normal file
View File

@ -0,0 +1,24 @@
export function curateRevision(revision) {
return {
id: revision.id,
sceneId: revision.scene_id,
actorId: revision.actor_id,
base: revision.base,
deltas: revision.deltas,
hash: revision.hash,
comment: revision.comment,
user: revision.user_id && {
id: revision.user_id,
username: revision.username,
},
review: typeof revision.approved === 'boolean' ? {
isApproved: revision.approved,
userId: revision.reviewed_by,
username: revision.reviewer_username,
reviewedAt: revision.reviewed_at,
} : null,
appliedAt: revision.applied_at,
failed: revision.failed,
createdAt: revision.created_at,
};
}

View File

@ -14,6 +14,7 @@ import { curateMedia } from './media.js';
import escape from '../utils/escape-manticore.js';
import promiseProps from '../utils/promise-props.js';
import initLogger from './logger.js';
import { curateRevision } from './revisions.js';
const logger = initLogger();
const mj = new MerkleJson();
@ -58,8 +59,6 @@ function curateScene(rawScene, assets) {
return null;
}
console.log(assets.chapters);
const curatedScene = {
id: rawScene.id,
title: rawScene.title,
@ -634,30 +633,6 @@ export async function fetchScenes(filters, rawOptions, reqUser) {
};
}
function curateRevision(revision) {
return {
id: revision.id,
sceneId: revision.scene_id,
base: revision.base,
deltas: revision.deltas,
hash: revision.hash,
comment: revision.comment,
user: revision.user_id && {
id: revision.user_id,
username: revision.username,
},
review: typeof revision.approved === 'boolean' ? {
isApproved: revision.approved,
userId: revision.reviewed_by,
username: revision.reviewer_username,
reviewedAt: revision.reviewed_at,
} : null,
appliedAt: revision.applied_at,
failed: revision.failed,
createdAt: revision.created_at,
};
}
export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
const limit = filters.limit || 50;
const page = filters.page || 1;
@ -727,6 +702,9 @@ export async function fetchSceneRevisions(revisionId, filters = {}, reqUser) {
const keyMap = {
productionDate: 'production_date',
productionLocation: 'production_location',
productionCity: 'production_city',
productionState: 'production_state',
};
async function applySceneValueDelta(sceneId, delta, trx) {
@ -952,6 +930,7 @@ export async function createSceneRevision(sceneId, { edits, comment, apply }, re
.returning('id');
if (['admin', 'editor'].includes(reqUser.role) && apply) {
await reviewSceneRevision(revisionEntry.id, true, {}, reqUser);
// don't keep the editor waiting for the revision to apply
reviewSceneRevision(revisionEntry.id, true, {}, reqUser);
}
}

View File

@ -1,6 +1,11 @@
import Router from 'express-promise-router';
import {
fetchActors,
fetchActorsById,
fetchActorRevisions,
createActorRevision,
reviewActorRevision,
} from '../actors.js';
export function curateActorsQuery(query) {
@ -131,11 +136,36 @@ export async function fetchActorsByIdGraphql(query, _req, _info) {
const actors = await fetchActorsById([].concat(query.id, query.ids).filter(Boolean));
const curatedActors = actors.map((actor) => curateGraphqlActor(actor));
console.log(actors);
if (query.ids) {
return curatedActors;
}
return curatedActors[0];
}
async function fetchActorRevisionsApi(req, res) {
const revisions = await fetchActorRevisions(Number(req.params.revisionId) || null, req.query, req.user);
res.send(revisions);
}
async function createActorRevisionApi(req, res) {
await createActorRevision(Number(req.body.actorId), req.body, req.user);
res.status(204).send();
}
async function reviewActorRevisionApi(req, res) {
await reviewActorRevision(Number(req.params.revisionId), req.body.isApproved, req.body, req.user);
res.status(204).send();
}
export const actorsRouter = Router();
actorsRouter.get('/api/actors', fetchActorsApi);
actorsRouter.get('/api/revisions/actors', fetchActorRevisionsApi);
actorsRouter.get('/api/revisions/actors/:revisionId', fetchActorRevisionsApi);
actorsRouter.post('/api/revisions/actors', createActorRevisionApi);
actorsRouter.post('/api/revisions/actors/:revisionId/reviews', reviewActorRevisionApi);

View File

@ -252,7 +252,7 @@ export const scenesRouter = Router();
scenesRouter.get('/api/scenes', fetchScenesApi);
scenesRouter.get('/api/scenes/:sceneId', fetchSceneApi);
scenesRouter.get('/api/revisions', fetchSceneRevisionsApi);
scenesRouter.get('/api/revisions/:revisionId', fetchSceneRevisionsApi);
scenesRouter.post('/api/revisions', createSceneRevisionApi);
scenesRouter.post('/api/revisions/:revisionId/reviews', reviewSceneRevisionApi);
scenesRouter.get('/api/revisions/scenes', fetchSceneRevisionsApi);
scenesRouter.get('/api/revisions/scenes/:revisionId', fetchSceneRevisionsApi);
scenesRouter.post('/api/revisions/scenes', createSceneRevisionApi);
scenesRouter.post('/api/revisions/scenes/:revisionId/reviews', reviewSceneRevisionApi);

View File

@ -14,8 +14,8 @@ import errorHandler from './error.js';
import consentHandler from './consent.js';
import { scenesRouter } from './scenes.js';
import { actorsRouter } from './actors.js';
import { fetchActorsApi } from './actors.js';
import { fetchMoviesApi } from './movies.js';
import { fetchEntitiesApi } from './entities.js';
import { fetchTagsApi } from './tags.js';
@ -144,9 +144,7 @@ export default async function initServer() {
router.use(userRouter);
router.use(stashesRouter);
router.use(scenesRouter);
// ACTORS
router.get('/api/actors', fetchActorsApi);
router.use(actorsRouter);
// MOVIES
router.get('/api/movies', fetchMoviesApi);

2
static

@ -1 +1 @@
Subproject commit 0f7fc7c9f4ba7454bb92482eaffd93b345ea9834
Subproject commit 083dfa7120cedc41757fe0da08d073faa6898e9f