This commit is contained in:
Salam-vsem
2025-05-24 19:02:39 +03:00
commit 2b72f5407b
52 changed files with 1457 additions and 0 deletions

5
server/src/bootstrap.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { Core } from '@strapi/strapi';
const bootstrap = ({ strapi }: { strapi: Core.Strapi }) => {};
export default bootstrap;

View File

@@ -0,0 +1,10 @@
import { Config } from '../types';
export default {
default: () => ({ contentVersionLimit: 50 }),
validator: (config: Config) => {
if (typeof config.contentVersionLimit !== 'number') {
throw new Error('contentVersionLimit has to be a boolean');
}
},
};

View File

@@ -0,0 +1,38 @@
export default {
kind: 'collectionType',
collectionName: 'content-version',
info: {
singularName: 'content-version', // kebab-case mandatory
pluralName: 'content-versions', // kebab-case mandatory
displayName: 'Версия контента',
},
pluginOptions: {
'content-manager': {
visible: false,
},
'content-type-builder': {
visible: false,
},
},
options: {
draftAndPublish: false,
},
attributes: {
contentId: {
type: 'string',
required: true,
},
model: {
type: 'string',
required: true,
},
data: {
type: 'json',
required: true,
},
createdAt: {
type: 'datetime',
default: () => new Date(),
},
},
};

View File

@@ -0,0 +1,5 @@
import contentVersion from './content-version';
export default {
'content-version': { schema: contentVersion },
};

View File

@@ -0,0 +1,5 @@
import version from './version';
export default {
version,
};

View File

@@ -0,0 +1,21 @@
import { Core } from '@strapi/strapi';
import { ContentVersionService } from '../services';
import { getService } from '../utils';
const versionController = ({ strapi }: { strapi: Core.Strapi }) => ({
async find(ctx) {
const { getVersions } = getService<ContentVersionService>('contentVersion');
const { model, contentId } = ctx.params;
return getVersions(contentId, model);
},
async getVersionFormData(ctx) {
const { getVersionFormData } = getService<ContentVersionService>('contentVersion');
const { versionId } = ctx.params;
const locale = ctx.request.query['locale'];
return getVersionFormData(versionId, locale);
},
});
export default versionController;

7
server/src/destroy.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { Core } from '@strapi/strapi';
const destroy = ({ strapi }: { strapi: Core.Strapi }) => {
// destroy phase
};
export default destroy;

30
server/src/index.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Application methods
*/
import bootstrap from './bootstrap';
import destroy from './destroy';
import register from './register';
/**
* Plugin server methods
*/
import config from './config';
import contentTypes from './content-types';
import controllers from './controllers';
import middlewares from './middlewares';
import policies from './policies';
import routes from './routes';
import services from './services';
export default {
register,
bootstrap,
destroy,
config,
controllers,
routes,
services,
contentTypes,
policies,
middlewares,
};

View File

@@ -0,0 +1,36 @@
import { Core, Internal, UID } from '@strapi/strapi';
import { get } from 'radash';
import { getService } from '../utils';
import { ContentVersionService } from '../services';
type RequestParams = {
model: UID.Schema;
documentId: string;
};
export const deleteWithVersions: Core.MiddlewareHandler = async (ctx, next) => {
const { model, documentId } = get<RequestParams>(ctx, 'request.params');
const modelDef = strapi.getModel(model);
const contentTypeService = getService('contentTypes');
const contentVersionService = getService<ContentVersionService>('contentVersion');
const modelDocumentService = strapi.documents(model as Internal.UID.ContentType);
if (!contentTypeService.isVersionedContentType(modelDef)) {
return next();
}
await strapi.db.transaction(async () => {
const ids = await modelDocumentService
.findMany({
fields: ['id'],
filters: {
documentId,
},
locale: ctx.request.query.locale as string | undefined,
})
.then((data) => data.map(({ id }) => id));
await contentVersionService.deleteVersionsForContent(ids);
return next();
});
};

View File

@@ -0,0 +1,4 @@
export default {};
export * from './publish-with-version';
export * from './delete-with-versions';

View File

@@ -0,0 +1,41 @@
import { Core, Internal, UID } from '@strapi/strapi';
import { get } from 'radash';
import { getDeepPopulate, getService } from '../utils';
import { CreateVersionInput, ContentVersionService } from '../services';
type RequestParams = {
model: UID.Schema;
documentId: string;
};
export const publishWithVersionMiddleware: Core.MiddlewareHandler = async (ctx, next) => {
const { model, documentId } = get<RequestParams>(ctx, 'request.params');
const modelDef = strapi.getModel(model);
const contentTypeService = getService('contentTypes');
const contentVersionService = getService<ContentVersionService>('contentVersion');
const modelDocumentService = strapi.documents(model as Internal.UID.ContentType);
if (!contentTypeService.isVersionedContentType(modelDef)) {
return next();
}
await strapi.db.transaction(async () => {
await next();
const deepPopulate = await getDeepPopulate(model);
const data = await modelDocumentService.findOne({
documentId,
locale: ctx.request.body.locale,
populate: deepPopulate,
});
if (!data) throw new Error(`Empty entry for documentid ${documentId}`);
const versionEntry: CreateVersionInput = {
model: model,
contentId: data.id,
data,
};
await contentVersionService.createVersion(versionEntry);
});
};

1
server/src/pluginId.ts Normal file
View File

@@ -0,0 +1 @@
export const PLUGIN_ID = 'content-versioning';

View File

@@ -0,0 +1 @@
export default {};

30
server/src/register.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { Core } from '@strapi/strapi';
import { deleteWithVersions, publishWithVersionMiddleware } from './middlewares';
const addDeleteWithVersionMiddleware = (strapi: Core.Strapi) =>
strapi.server.router.use('/content-manager/collection-types/:model/:documentId', (ctx, next) => {
if (ctx.method === 'DELETE') {
return deleteWithVersions(ctx, next);
}
return next();
});
const addPublishWithVersionMiddleware = (strapi: Core.Strapi) =>
strapi.server.router.use(
'/content-manager/collection-types/:model/:documentId/actions/publish',
(ctx, next) => {
if (ctx.method === 'POST') {
return publishWithVersionMiddleware(ctx, next);
}
return next();
}
);
const register = ({ strapi }: { strapi: Core.Strapi }) => {
addPublishWithVersionMiddleware(strapi);
addDeleteWithVersionMiddleware(strapi);
};
export default register;

View File

@@ -0,0 +1,12 @@
export default [
{
method: 'GET',
path: '/version/:model/:contentId',
handler: 'version.find',
},
{
method: 'GET',
path: '/version-form-data/:versionId',
handler: 'version.getVersionFormData',
},
];

View File

@@ -0,0 +1,10 @@
import { get } from 'radash';
const hasVersionedOption = (modelOrAttribute) =>
get(modelOrAttribute, 'pluginOptions.versions.versioned', false);
export const isVersionedContentType = (model) => hasVersionedOption(model);
export default {
isVersionedContentType,
};

View File

@@ -0,0 +1,110 @@
import type { Core } from '@strapi/strapi';
import { PLUGIN_ID } from '../pluginId';
import { Config, ContentVersion } from '../types';
import { getConfig, getDeepPopulate, prepareVersionFormData } from '../utils';
import { get } from 'radash';
export const CONTENT_VERSION_ID = `${PLUGIN_ID}.content-version`;
export const PLUGIN_CONTENT_VERSION_ID = `plugin::${CONTENT_VERSION_ID}`;
export type CreateVersionInput = Pick<ContentVersion, 'contentId' | 'data' | 'model'>;
export type ContentVersionService = ReturnType<typeof contentVersionService>;
const contentVersionService = ({ strapi }: { strapi: Core.Strapi }) => ({
async createVersion(data: CreateVersionInput) {
return await strapi.db.transaction(async () => {
const config = getConfig();
const contentVersionLimit = config<Config['contentVersionLimit']>('contentVersionLimit');
const newVersion = await strapi.query(PLUGIN_CONTENT_VERSION_ID).create({ data });
const allVersions = await strapi.query(PLUGIN_CONTENT_VERSION_ID).findMany({
select: 'id',
where: { contentId: data.contentId },
orderBy: { createdAt: 'desc' },
});
if (allVersions.length > contentVersionLimit) {
await strapi.query(PLUGIN_CONTENT_VERSION_ID).deleteMany({
where: {
id: {
$in: allVersions.slice(contentVersionLimit).map((item) => get(item, 'id')),
},
},
});
}
return newVersion;
});
},
async getVersions(
contentId: CreateVersionInput['contentId'],
model: CreateVersionInput['model']
) {
return strapi.query(PLUGIN_CONTENT_VERSION_ID).findMany({
select: ['id', 'contentId', 'documentId', 'createdAt'],
where: { contentId, model },
orderBy: { createdAt: 'desc' },
});
},
async getVersion(versionId: string) {
return strapi.query(PLUGIN_CONTENT_VERSION_ID).findOne({
where: { id: versionId },
});
},
async deleteVersionsForContent(contentIds: CreateVersionInput['contentId'][]) {
return strapi.query(PLUGIN_CONTENT_VERSION_ID).deleteMany({
where: {
contentId: {
$in: contentIds,
},
},
});
},
async getVersionFormData(versionId: string, locale: string = null) {
const version: ContentVersion = await strapi
.query(PLUGIN_CONTENT_VERSION_ID)
.findOne({ where: { id: versionId } });
if (!version) throw new Error(`version with id ${versionId} not found`);
const { model, data, contentId } = version;
const contentTypesService = strapi.plugin('content-manager').services['content-types'];
const contentTypeSchema = strapi.getModel(model);
const componentsSchema = strapi.components;
const deepPopulate = await getDeepPopulate(model);
const [contentTypeSettings, componentsSettings, currentData] = await Promise.all([
contentTypesService.findConfiguration(contentTypeSchema),
contentTypesService.findComponentsConfigurations(contentTypeSchema),
strapi.db
.query(model)
.findOne({ where: { id: contentId }, select: ['documentId'] })
.then(({ documentId }) =>
//@ts-expect-error
strapi.documents(model).findOne({
populate: deepPopulate,
locale,
documentId,
})
),
]);
const result = await prepareVersionFormData({
schema: contentTypeSchema,
configuration: contentTypeSettings,
componentsConfiguration: componentsSettings,
componentsSchema,
versionData: data,
currentData,
locale,
});
return result;
},
});
export default contentVersionService;

View File

@@ -0,0 +1,9 @@
import contentTypes from './content-types';
import contentVersion from './content-version';
export { type ContentVersionService, CreateVersionInput } from './content-version';
export default {
contentVersion,
contentTypes,
};

46
server/src/types.ts Normal file
View File

@@ -0,0 +1,46 @@
import { UID } from '@strapi/strapi';
export type ContentVersion<T = Record<string, unknown>> = {
documentId: string;
contentId: number;
model: UID.Schema;
createdAt: string;
data: T;
id: number;
};
export type FormRelationFieldAttributes = {
id: number;
documentId: string;
locale: string | null;
};
export type FormRelationConnection = FormRelationFieldAttributes & {
href?: string;
label: string;
apiData?: FormRelationFieldAttributes;
};
export type FormRelation = {
connect?: FormRelationConnection[];
disconnect?: FormRelationConnection[];
};
export type Metadata = {
edit: {
label: string;
description: string;
placeholder: string;
visible: boolean;
editable: boolean;
mainField?: string;
};
};
export type Configuration = {
metadatas: Record<string, Metadata>;
};
export type Config = {
contentVersionLimit: number;
};

View File

@@ -0,0 +1,9 @@
export const getRelationLabel = (content: Record<string, unknown>, mainField?: string): string => {
const label = mainField && content[mainField] ? content[mainField] : null;
if (typeof label === 'string') {
return label;
}
return content.documentId as string;
};

16
server/src/utils/index.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Core, UID } from '@strapi/strapi';
import { PLUGIN_ID } from '../pluginId';
export * from './prepareVersionFormData';
export * from './getRelationLabel';
export const getService = <T = Core.Service>(name: string) =>
strapi.plugin(PLUGIN_ID).service(name) as T;
export const getConfig = () => strapi.plugin(PLUGIN_ID).config;
export const getDeepPopulate = async (model: UID.Schema) =>
// @ts-expect-error
strapi.plugin('content-manager').services['populate-builder'](model).populateDeep().build();
export default {};

View File

@@ -0,0 +1,5 @@
import { UID } from '@strapi/strapi';
export const isLocalizedContentType = (model: UID.Schema) => {
return strapi.plugin('i18n')?.service('content-types')?.isLocalizedContentType(model);
};

View File

@@ -0,0 +1,223 @@
import {Schema, UID} from '@strapi/strapi';
import {chain, get, isArray, isEmpty, isEqual, isNumber, pick} from 'radash';
import {Configuration, Metadata, FormRelationConnection, FormRelation} from '../types';
import {getRelationLabel} from './getRelationLabel';
import {withTempKeys} from './withTempKeys';
type PrepareFormDataProps = {
currentData: unknown;
versionData: unknown;
schema: Schema.ContentType | Schema.Component;
componentsSchema?: Schema.Components;
configuration: Configuration;
componentsConfiguration?: Record<UID.Component, Configuration>;
locale: string | null;
};
type RelationValue = {id: number; documentId: string} & Record<string, unknown>;
type PrepareRelationProps = {
metadata?: Metadata;
currentData?: RelationValue | RelationValue[];
versionData?: RelationValue | RelationValue[];
locale: string | null;
};
const toArray = <T>(data?: T | T[]) => (isArray(data) ? data : [data]);
const isEqualIds = (prev?: unknown, curr?: unknown) => {
const currId = get(curr, 'id')
const prevId = get(prev, 'id')
return isNumber(prevId) && isNumber(currId) && isEqual(currId, prevId)
}
const makeConnection = (
relation: RelationValue,
locale: string | null,
mainField?: string
): FormRelationConnection => {
const mainIdentifiers = pick(relation, ['documentId', 'id']);
return {
...mainIdentifiers,
apiData: {
...mainIdentifiers,
locale,
},
[mainField ?? 'documentId']: relation[mainField ?? 'documentId'],
label: getRelationLabel(relation, mainField),
locale,
};
};
const fetchFreshRelationData = (
data: RelationValue | RelationValue[],
schema: Schema.Attribute.Relation,
locale: string | null,
metadata?: Metadata
) => {
const payload = data
? toArray(data)
.map((item: RelationValue) => get(item, 'id'))
.filter(Boolean)
: [];
return !isEmpty(payload)
? strapi
// @ts-expect-error
.documents(schema.target)
.findMany({
locale,
filters: {id: {$in: payload}},
fields: ['id', 'documentId', metadata?.edit?.mainField].filter(Boolean),
})
: [];
};
const notIncludesRelation = (oldVal: RelationValue[]) => (newVal: RelationValue) =>
oldVal.every(({id}) => newVal.id !== id);
const prepareRelation = ({
currentData = [],
versionData = [],
metadata,
locale,
}: PrepareRelationProps): FormRelation => {
const currentRelationsArray = toArray(currentData).filter((relation) => !!relation?.id);
const versionRelationArray = toArray(versionData).filter((relation) => !!relation?.id);
if (isEmpty(currentData) && isEmpty(versionData)) return {connect: [], disconnect: []};
const relationsToConnect = versionRelationArray.filter(
notIncludesRelation(currentRelationsArray)
);
const relationsToDisconnect = currentRelationsArray.filter(
notIncludesRelation(versionRelationArray)
);
const mainField = get<string | undefined>(metadata, 'edit.mainField');
const connect: FormRelationConnection[] = relationsToConnect.map((relation) =>
makeConnection(relation, locale, mainField)
);
const disconnect: FormRelationConnection[] = relationsToDisconnect.map((relation) =>
makeConnection(relation, locale, mainField)
);
return {connect, disconnect};
};
export const prepareVersionFormData = async ({
componentsConfiguration,
componentsSchema,
locale,
...otherProps
}: PrepareFormDataProps) => {
const _prepareVersionFormData = async ({
schema,
configuration,
currentData,
versionData,
}: Omit<PrepareFormDataProps, 'componentsSchema' | 'componentsConfiguration' | 'locale'>) => {
if (!schema || isEmpty(schema)) throw new Error('Schema was not provided');
const result = pick(versionData as any, ['__component', isEqualIds(currentData, versionData) && 'id']);
for (const [attributeKey, attributeValue] of Object.entries(schema.attributes)) {
if (attributeValue.writable === false) {
continue;
}
switch (attributeValue.type) {
case 'relation':
const relationConfiguration = configuration?.metadatas?.[attributeKey];
const freshVersionRelations = await fetchFreshRelationData(
get(versionData, attributeKey),
attributeValue,
locale,
relationConfiguration
);
const relationField = prepareRelation({
currentData: get(currentData, attributeKey),
versionData: freshVersionRelations,
metadata: relationConfiguration,
locale,
});
Object.assign(result, {[attributeKey]: relationField});
break;
case 'dynamiczone':
const currentDynamicZone = get<any[]>(currentData, attributeKey);
const versionDynamicZone = get<any[]>(versionData, attributeKey).filter(
({__component}) => !!componentsSchema[__component]
);
const dynamicZoneField = await Promise.all(
versionDynamicZone.map((data) =>
_prepareVersionFormData({
currentData: currentDynamicZone?.find(
({__component}) => __component === data.__component
),
versionData: data,
schema: componentsSchema[data.__component],
configuration: componentsConfiguration?.[data.__component],
})
)
).then(chain((res) => res.filter((item) => !isEmpty(item)), withTempKeys));
if (!isEmpty(dynamicZoneField)) {
Object.assign(result, {[attributeKey]: dynamicZoneField});
}
break;
case 'component':
const component = get<string>(attributeValue, 'component');
const versionComponent = get(versionData, attributeKey);
if (!versionComponent || isEmpty(versionComponent)) {
break;
}
const request = {
schema: componentsSchema?.[component],
configuration: componentsConfiguration?.[component],
locale,
};
const componentField = attributeValue.repeatable
? await Promise.all(
toArray(versionComponent).map((data: any) =>
_prepareVersionFormData({
...request,
versionData: data,
currentData: toArray(currentData?.[attributeKey]).find(
chain(
(item) => get(item, 'id'),
(id) => isEqual(id, data.id)
)
),
})
)
).then(withTempKeys)
: await _prepareVersionFormData({
...request,
versionData: versionComponent,
currentData: currentData?.[attributeKey],
});
if (!isEmpty(componentField)) {
Object.assign(result, {
[attributeKey]: componentField,
});
}
break;
default:
if (versionData?.[attributeKey] !== undefined) {
Object.assign(result, {[attributeKey]: versionData[attributeKey]});
}
}
}
return result;
};
return _prepareVersionFormData(otherProps);
};

View File

@@ -0,0 +1,7 @@
import { generateNKeysBetween } from 'fractional-indexing';
import { assign } from 'radash';
export const withTempKeys = (array: unknown[]) => {
const keys = generateNKeysBetween(undefined, undefined, array.length);
return array.map((item, index) => assign(item, { __temp_key__: keys[index] }));
};

View File

@@ -0,0 +1,10 @@
{
"extends": "./tsconfig",
"include": ["./src"],
"exclude": ["**/*.test.ts"],
"compilerOptions": {
"rootDir": "../",
"baseUrl": ".",
"outDir": "./dist"
}
}

8
server/tsconfig.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "@strapi/typescript-utils/tsconfigs/server",
"include": ["./src"],
"compilerOptions": {
"rootDir": "../",
"baseUrl": "."
}
}