refactor(backend): remove content excerpt (#9441)

This commit is contained in:
Ulf Gebhardt 2026-03-27 14:38:46 +01:00 committed by GitHub
parent 33f522ed16
commit b088ec9e62
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 108 additions and 122 deletions

View File

@ -199,9 +199,6 @@ Factory.define('post')
// Convert false to null
return pinned || null
})
.attr('contentExcerpt', ['contentExcerpt', 'content'], (contentExcerpt, content) => {
return contentExcerpt || content
})
.attr('slug', ['slug', 'title'], (slug, title) => {
return slug || slugify(title, { lower: true })
})
@ -294,9 +291,6 @@ Factory.define('comment')
id: uuid,
content: faker.lorem.sentence,
})
.attr('contentExcerpt', ['contentExcerpt', 'content'], (contentExcerpt, content) => {
return contentExcerpt || content
})
.after(async (buildObject, options) => {
const [comment, author, post] = await Promise.all([
neode.create('Comment', buildObject),

View File

@ -0,0 +1,35 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { getDriver } from '@db/neo4j'
export const description = 'Remove contentExcerpt property from Post and Comment nodes'
export async function up(_next) {
const driver = getDriver()
const session = driver.session()
const transaction = session.beginTransaction()
try {
await transaction.run(`
MATCH (n)
WHERE n:Post OR n:Comment
REMOVE n.contentExcerpt
`)
await transaction.commit()
} catch (error) {
// eslint-disable-next-line no-console
console.log(error)
await transaction.rollback()
// eslint-disable-next-line no-console
console.log('rolled back')
throw new Error(error)
} finally {
await session.close()
}
}
export function down(_next) {
throw new Error(
'Irreversible migration: contentExcerpt was removed and cannot be restored without regenerating from content',
)
}

View File

@ -10,7 +10,6 @@ export default {
default: () => new Date().toISOString(),
},
content: { type: 'string', disallow: [null], min: 3 },
contentExcerpt: { type: 'string', allow: [null] },
deleted: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false },
post: {

View File

@ -19,7 +19,6 @@ export default {
title: { type: 'string', disallow: [null], min: 3 },
slug: { type: 'string', allow: [null], unique: 'true' },
content: { type: 'string', disallow: [null], required: true, min: 3 },
contentExcerpt: { type: 'string', allow: [null] },
deleted: { type: 'boolean', default: false },
disabled: { type: 'boolean', default: false },
clickedCount: { type: 'int', default: 0 },

View File

@ -2,7 +2,6 @@ import type { Integer, Node } from 'neo4j-driver'
export interface CommentDbProperties {
content: string
contentExcerpt: string
createdAt: string
deleted: boolean
disabled: boolean

View File

@ -3,7 +3,6 @@ import type { Integer, Node } from 'neo4j-driver'
export interface PostDbProperties {
clickedCount: number
content: string
contentExcerpt: string
createdAt: string
deleted: boolean
disabled: boolean

View File

@ -2,7 +2,6 @@ mutation DeleteComment($id: ID!) {
DeleteComment(id: $id) {
id
content
contentExcerpt
deleted
}
}

View File

@ -3,7 +3,6 @@ mutation DeletePost($id: ID!) {
id
deleted
content
contentExcerpt
image {
url
}
@ -11,7 +10,6 @@ mutation DeletePost($id: ID!) {
id
deleted
content
contentExcerpt
}
}
}

View File

@ -3,7 +3,6 @@ query Post($id: ID, $filter: _PostFilter, $first: Int, $offset: Int, $orderBy: [
id
title
content
contentExcerpt
eventStart
pinned
createdAt

View File

@ -7,19 +7,16 @@ mutation DeleteUser($id: ID!, $resource: [Deletable]) {
contributions {
id
content
contentExcerpt
deleted
comments {
id
content
contentExcerpt
deleted
}
}
comments {
id
content
contentExcerpt
deleted
}
}

View File

@ -29,7 +29,6 @@ query User($id: ID, $name: String, $email: String) {
comments {
id
content
contentExcerpt
}
contributions {
id
@ -39,7 +38,6 @@ query User($id: ID, $name: String, $email: String) {
url
}
content
contentExcerpt
}
}
isMuted

View File

@ -30,7 +30,6 @@ query UserEmail($id: ID, $name: String, $email: String) {
comments {
id
content
contentExcerpt
}
contributions {
id
@ -40,7 +39,6 @@ query UserEmail($id: ID, $name: String, $email: String) {
url
}
content
contentExcerpt
}
}
isMuted

View File

@ -29,7 +29,6 @@ query UserEmailNotificationSettings($id: ID, $name: String, $email: String) {
comments {
id
content
contentExcerpt
}
contributions {
id
@ -39,7 +38,6 @@ query UserEmailNotificationSettings($id: ID, $name: String, $email: String) {
url
}
content
contentExcerpt
}
}
isMuted

View File

@ -240,7 +240,6 @@ describe('DeleteComment', () => {
id: 'c456',
deleted: true,
content: 'UNAVAILABLE',
contentExcerpt: 'UNAVAILABLE',
},
}
expect(data).toMatchObject(expected)

View File

@ -83,7 +83,6 @@ export default {
MATCH (comment:Comment {id: $commentId})
SET comment.deleted = TRUE
SET comment.content = 'UNAVAILABLE'
SET comment.contentExcerpt = 'UNAVAILABLE'
RETURN comment
`,
{ commentId: args.id },

View File

@ -2000,7 +2000,6 @@ describe('DeletePost', () => {
id: 'p4711',
deleted: true,
content: 'UNAVAILABLE',
contentExcerpt: 'UNAVAILABLE',
image: null,
comments: [],
},
@ -2015,7 +2014,6 @@ describe('DeletePost', () => {
'comment',
{
content: 'to be deleted comment content',
contentExcerpt: 'to be deleted comment content',
},
{
postId: 'p4711',
@ -2030,14 +2028,12 @@ describe('DeletePost', () => {
id: 'p4711',
deleted: true,
content: 'UNAVAILABLE',
contentExcerpt: 'UNAVAILABLE',
image: null,
comments: [
{
deleted: true,
// Should we black out the comment content in the database, too?
content: 'UNAVAILABLE',
contentExcerpt: 'UNAVAILABLE',
},
],
},

View File

@ -296,7 +296,6 @@ export default {
OPTIONAL MATCH (post)<-[:COMMENTS]-(comment:Comment)
SET post.deleted = TRUE
SET post.content = 'UNAVAILABLE'
SET post.contentExcerpt = 'UNAVAILABLE'
SET post.title = 'UNAVAILABLE'
SET comment.deleted = TRUE
RETURN post {.*}

View File

@ -354,13 +354,11 @@ describe('Delete a User as admin', () => {
{
id: 'p139',
content: 'Post by user u343',
contentExcerpt: 'Post by user u343',
deleted: false,
comments: [
{
id: 'c156',
content: "A comment by someone else on user u343's post",
contentExcerpt: "A comment by someone else on user u343's post",
deleted: false,
},
],
@ -370,7 +368,6 @@ describe('Delete a User as admin', () => {
{
id: 'c155',
content: 'Comment by user u343',
contentExcerpt: 'Comment by user u343',
deleted: false,
},
],
@ -400,13 +397,11 @@ describe('Delete a User as admin', () => {
{
id: 'p139',
content: 'UNAVAILABLE',
contentExcerpt: 'UNAVAILABLE',
deleted: true,
comments: [
{
id: 'c156',
content: 'UNAVAILABLE',
contentExcerpt: 'UNAVAILABLE',
deleted: true,
},
],
@ -416,7 +411,6 @@ describe('Delete a User as admin', () => {
{
id: 'c155',
content: 'UNAVAILABLE',
contentExcerpt: 'UNAVAILABLE',
deleted: true,
},
],

View File

@ -223,7 +223,6 @@ export default {
OPTIONAL MATCH (resource)<-[:COMMENTS]-(comment:Comment)
SET resource.deleted = true
SET resource.content = 'UNAVAILABLE'
SET resource.contentExcerpt = 'UNAVAILABLE'
SET resource.language = 'UNAVAILABLE'
SET resource.createdAt = 'UNAVAILABLE'
SET resource.updatedAt = 'UNAVAILABLE'

View File

@ -41,7 +41,6 @@ type Comment {
activityId: String
author: User @relation(name: "WROTE", direction: "IN")
content: String!
contentExcerpt: String
post: Post @relation(name: "COMMENTS", direction: "OUT")
createdAt: String
updatedAt: String
@ -81,7 +80,7 @@ type Query {
}
type Mutation {
CreateComment(id: ID, postId: ID!, content: String!, contentExcerpt: String): Comment
UpdateComment(id: ID!, content: String!, contentExcerpt: String): Comment
CreateComment(id: ID, postId: ID!, content: String!): Comment
UpdateComment(id: ID!, content: String!): Comment
DeleteComment(id: ID!): Comment
}

View File

@ -129,7 +129,6 @@ type Post {
title: String!
slug: String!
content: String!
contentExcerpt: String
image: Image @relation(name: "HERO_IMAGE", direction: "OUT")
visibility: Visibility
deleted: Boolean
@ -230,7 +229,6 @@ type Mutation {
visibility: Visibility
language: String
categoryIds: [ID]
contentExcerpt: String
groupId: ID
postType: PostType = Article
eventInput: _EventInput
@ -240,7 +238,6 @@ type Mutation {
title: String!
slug: String
content: String!
contentExcerpt: String
image: ImageInput
visibility: Visibility
language: String

View File

@ -20,33 +20,9 @@ const updateGroup: IMiddlewareResolver = async (resolve, root, args, context, in
return resolve(root, args, context, info)
}
const createPost: IMiddlewareResolver = async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 120).html
return resolve(root, args, context, info)
}
const updatePost: IMiddlewareResolver = async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 120).html
return resolve(root, args, context, info)
}
const createComment: IMiddlewareResolver = async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 180).html
return resolve(root, args, context, info)
}
const updateComment: IMiddlewareResolver = async (resolve, root, args, context, info) => {
args.contentExcerpt = trunc(args.content, 180).html
return resolve(root, args, context, info)
}
export default {
Mutation: {
CreateGroup: createGroup,
UpdateGroup: updateGroup,
CreatePost: createPost,
UpdatePost: updatePost,
CreateComment: createComment,
UpdateComment: updateComment,
},
}

View File

@ -116,7 +116,6 @@ beforeAll(async () => {
id: 'p2',
title: 'Disabled post',
content: 'This is an offensive post content',
contentExcerpt: 'This is an offensive post content',
deleted: false,
},
{
@ -132,7 +131,6 @@ beforeAll(async () => {
{
id: 'c1',
content: 'Disabled comment',
contentExcerpt: 'Disabled comment',
},
{
author: troll,
@ -252,9 +250,6 @@ describe('softDeleteMiddleware', () => {
it('displays content', () => {
expect(subject.content).toEqual('This is an offensive post content')
})
it('displays contentExcerpt', () => {
expect(subject.contentExcerpt).toEqual('This is an offensive post content')
})
it('displays image', () => {
expect(subject.image).toEqual({
url: expect.stringMatching('http://localhost/some/offensive/image.jpg'),
@ -268,9 +263,6 @@ describe('softDeleteMiddleware', () => {
it('displays content', () => {
expect(subject.content).toEqual('Disabled comment')
})
it('displays contentExcerpt', () => {
expect(subject.contentExcerpt).toEqual('Disabled comment')
})
})
})
@ -308,9 +300,6 @@ describe('softDeleteMiddleware', () => {
it('obfuscates content', () => {
expect(subject.content).toEqual('UNAVAILABLE')
})
it('obfuscates contentExcerpt', () => {
expect(subject.contentExcerpt).toEqual('UNAVAILABLE')
})
it('obfuscates image', () => {
expect(subject.image).toEqual(null)
})
@ -322,9 +311,6 @@ describe('softDeleteMiddleware', () => {
it('obfuscates content', () => {
expect(subject.content).toEqual('UNAVAILABLE')
})
it('obfuscates contentExcerpt', () => {
expect(subject.contentExcerpt).toEqual('UNAVAILABLE')
})
})
})
})

View File

@ -20,7 +20,6 @@ const setDefaultFilters: IMiddlewareResolver = async (resolve, root, args, conte
const obfuscate: IMiddlewareResolver = async (resolve, root, args, context, info) => {
if (root.deleted || (!isModerator(context) && root.disabled)) {
root.content = 'UNAVAILABLE'
root.contentExcerpt = 'UNAVAILABLE'
root.title = 'UNAVAILABLE'
root.slug = 'UNAVAILABLE'
root.avatar = null

View File

@ -41,7 +41,7 @@ const walkRecursive = (data, fields, fieldName, callback, _key?) => {
// exclamation mark separates field names, that should not be sanitized
const fields = [
{ field: 'content', excludes: ['CreateMessage', 'Message'] },
{ field: 'contentExcerpt' },
{ field: 'reasonDescription' },
{ field: 'description', excludes: ['embed'] },
{ field: 'descriptionExcerpt' },

View File

@ -34,7 +34,7 @@ defineStep('I see all the reported posts including the one from above', () => {
}
... on Comment {
id
contentExcerpt
content
disabled
deleted
author {

View File

@ -159,7 +159,7 @@ export default {
titleIdent: 'delete.comment.title',
messageIdent: 'delete.comment.message',
messageParams: {
name: this.$filters.truncate(this.comment.contentExcerpt, 30),
name: this.$filters.truncate(this.$filters.removeHtml(this.comment.content), 30),
},
buttons: {
confirm: {

View File

@ -100,7 +100,7 @@ describe('NotificationsTable.vue', () => {
it("renders the Post's content", () => {
const boldTags = firstRowNotification.findAll('p')
const content = boldTags.filter(
(element) => element.text() === postNotification.from.contentExcerpt,
(element) => element.text() === postNotification.from.content,
)
expect(content.exists()).toBe(true)
})
@ -133,12 +133,34 @@ describe('NotificationsTable.vue', () => {
it("renders the Post's content", () => {
const boldTags = secondRowNotification.findAll('p')
const content = boldTags.filter(
(element) => element.text() === commentNotification.from.contentExcerpt,
(element) => element.text() === commentNotification.from.content,
)
expect(content.exists()).toBe(true)
})
})
describe('fallback to descriptionExcerpt when content is empty', () => {
it('renders descriptionExcerpt if content is missing', () => {
const fallbackNotification = {
read: false,
reason: 'mentioned_in_post',
from: {
__typename: 'Post',
id: 'post-fallback',
title: 'fallback post',
slug: 'fallback-post',
content: '',
descriptionExcerpt: 'fallback description text',
author: { id: 'u1', slug: 'user', name: 'User' },
},
}
propsData.notifications = [fallbackNotification]
wrapper = Wrapper()
const description = wrapper.find('.notification-description')
expect(description.text()).toBe('fallback description text')
})
})
describe('unread status', () => {
it('does not have class `notification-status`', () => {
expect(wrapper.find('.notification-status').exists()).toBe(false)

View File

@ -32,8 +32,6 @@ export const notifications = [
deleted: false,
content:
'<p><a class="mention" href="/profile/u1" data-mention-id="u1" target="_blank">@peter-lustig</a> </p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra magna ac placerat. Tempor id eu nisl nunc mi ipsum faucibus vitae. Nibh praesent tristique magna sit amet purus gravida quis blandit. Magna eget est lorem ipsum dolor. In fermentum posuere urna nec. Eleifend donec pretium vulputate sapien nec sagittis aliquam. Augue interdum velit euismod in pellentesque. Id diam maecenas ultricies mi eget mauris pharetra. Donec pretium vulputate sapien nec. Dolor morbi non arcu risus quis varius quam quisque. Blandit turpis cursus in hac habitasse. Est ultricies integer quis auctor elit sed vulputate mi sit. Nunc consequat interdum varius sit amet mattis vulputate enim. Semper feugiat nibh sed pulvinar. Eget felis eget nunc lobortis mattis aliquam. Ultrices vitae auctor eu augue. Tellus molestie nunc non blandit massa enim nec dui. Pharetra massa massa ultricies mi quis hendrerit dolor. Nisl suscipit adipiscing bibendum est ultricies integer.</p>',
contentExcerpt:
'<p><a href="/profile/u1" target="_blank">@peter-lustig</a> </p><p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra …</p>',
...post,
author: user,
},
@ -53,8 +51,6 @@ export const notifications = [
deleted: false,
content:
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra magna ac placerat. Tempor id eu nisl nunc mi ipsum faucibus vitae. Nibh praesent tristique magna sit amet purus gravida quis blandit. Magna eget est lorem ipsum dolor. In fermentum posuere urna nec. Eleifend donec pretium vulputate sapien nec sagittis aliquam. Augue interdum velit euismod in pellentesque. Id diam maecenas ultricies mi eget mauris pharetra. Donec pretium vulputate sapien nec. Dolor morbi non arcu risus quis varius quam quisque. Blandit turpis cursus in hac habitasse. Est ultricies integer quis auctor elit sed vulputate mi sit. Nunc consequat interdum varius sit amet mattis vulputate enim. Semper feugiat nibh sed pulvinar. Eget felis eget nunc lobortis mattis aliquam. Ultrices vitae auctor eu augue. Tellus molestie nunc non blandit massa enim nec dui. Pharetra massa massa ultricies mi quis hendrerit dolor. Nisl suscipit adipiscing bibendum est ultricies integer.</p><p><a class="mention" href="/profile/u1" data-mention-id="u1" target="_blank">@peter-lustig</a> </p><p></p>',
contentExcerpt:
'<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Turpis egestas pretium aenean pharetra magna ac …</p>',
...post,
author: user,
},

View File

@ -72,8 +72,8 @@
:class="{ 'notification-status': notification.read }"
>
{{
notification.from.contentExcerpt ||
notification.from.descriptionExcerpt | removeHtml
$filters.removeHtml(notification.from.content) ||
$filters.removeHtml(notification.from.descriptionExcerpt)
}}
</p>
</div>

View File

@ -66,9 +66,7 @@
/>
</div>
</client-only>
<!-- TODO: replace editor content with tiptap render view -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="content hyphenate-text" v-html="excerpt" />
<div class="content hyphenate-text">{{ excerpt }}</div>
<footer
class="footer"
v-observe-visibility="(isVisible, entry) => visibilityChanged(isVisible, entry, post.id)"
@ -232,7 +230,7 @@ export default {
user: 'auth/user',
}),
excerpt() {
return this.$filters.removeLinks(this.post.contentExcerpt)
return this.$filters.removeHtml(this.post.content)
},
isAuthor() {
const { author } = this.post
@ -399,6 +397,10 @@ export default {
.content {
flex-grow: 1;
margin-bottom: $space-small;
display: -webkit-box;
-webkit-line-clamp: 6;
-webkit-box-orient: vertical;
overflow: hidden;
}
.footer {

View File

@ -90,7 +90,7 @@ export default {
name:
report.resource.name ||
this.$filters.truncate(report.resource.title, 30) ||
this.$filters.truncate(this.$filters.removeHtml(report.resource.contentExcerpt), 30),
this.$filters.truncate(this.$filters.removeHtml(report.resource.content), 30),
},
buttons: {
confirm: {

View File

@ -153,9 +153,7 @@ export default {
}
},
linkText() {
return (
this.report.resource.title || this.$filters.removeHtml(this.report.resource.contentExcerpt)
)
return this.report.resource.title || this.$filters.removeHtml(this.report.resource.content)
},
statusIconName() {
return this.isDisabled ? this.icons.eyeSlash : this.icons.eye

View File

@ -7,7 +7,7 @@ export const notifications = [
id: 'post-1',
title: 'some post title',
slug: 'some-post-title',
contentExcerpt: 'this is a post content',
content: 'this is a post content',
author: {
id: 'john-1',
slug: 'john-doe',
@ -21,12 +21,12 @@ export const notifications = [
from: {
__typename: 'Comment',
id: 'comment-2',
contentExcerpt: 'this is yet another post content',
content: 'this is yet another post content',
post: {
id: 'post-1',
title: 'some post on a comment',
slug: 'some-post-on-a-comment',
contentExcerpt: 'this is a post content',
content: 'this is a post content',
author: {
id: 'john-1',
slug: 'john-doe',

View File

@ -9,7 +9,7 @@ export default () => {
mutation ($postId: ID!, $content: String!) {
CreateComment(postId: $postId, content: $content) {
id
contentExcerpt
content
createdAt
updatedAt
@ -45,7 +45,7 @@ export default () => {
mutation ($content: String!, $id: ID!) {
UpdateComment(content: $content, id: $id) {
id
contentExcerpt
content
createdAt
updatedAt
@ -70,7 +70,7 @@ export default () => {
mutation ($id: ID!) {
DeleteComment(id: $id) {
id
contentExcerpt
content
createdAt
disabled

View File

@ -8,7 +8,7 @@ export default () => {
query Comment($postId: ID) {
Comment(postId: $postId) {
id
contentExcerpt
content
createdAt
author {
id

View File

@ -42,7 +42,7 @@ export const reportsListQuery = () => {
}
... on Comment {
id
contentExcerpt
content
disabled
deleted
author {

View File

@ -32,7 +32,7 @@ export default () => {
slug
title
content
contentExcerpt
language
image {
...imageUrls
@ -83,7 +83,7 @@ export default () => {
title
slug
content
contentExcerpt
language
image {
...imageUrls
@ -146,7 +146,7 @@ export default () => {
title
slug
content
contentExcerpt
language
pinnedBy {
id
@ -163,7 +163,7 @@ export default () => {
title
slug
content
contentExcerpt
language
pinnedBy {
id
@ -180,7 +180,7 @@ export default () => {
title
slug
content
contentExcerpt
language
pinnedBy {
id
@ -197,7 +197,7 @@ export default () => {
title
slug
content
contentExcerpt
language
pinnedBy {
id
@ -214,7 +214,7 @@ export default () => {
title
slug
content
contentExcerpt
language
pinnedBy {
id
@ -231,7 +231,7 @@ export default () => {
title
slug
content
contentExcerpt
language
pinnedBy {
id

View File

@ -8,7 +8,6 @@ export const comment = gql`
disabled
deleted
content
contentExcerpt
isPostObservedByMe
postObservingUsersCount
shoutedByCurrentUser

View File

@ -8,7 +8,6 @@ export const post = gql`
id
title
content
contentExcerpt
createdAt
updatedAt
sortDate

View File

@ -434,7 +434,7 @@ export default {
id: post.id,
slug: post.slug,
name: post.title,
description: post.contentExcerpt,
description: this.$filters.removeHtml(post.content),
},
geometry: {
type: 'Point',

View File

@ -87,11 +87,26 @@ export default ({ app = {} }) => {
if (!content) return ''
let contentExcerpt = content
if (replaceLinebreaks) {
// replace linebreaks with spaces first
contentExcerpt = contentExcerpt.replace(/<br>/gim, ' ').trim()
// replace linebreaks and block-level closing tags with spaces
contentExcerpt = contentExcerpt
.replace(/<\/(p|h[1-6]|li|div|blockquote)>/gim, ' ')
.replace(/<br\s*\/?>/gim, ' ')
.trim()
}
// remove the rest of the HTML
contentExcerpt = contentExcerpt.replace(/<(?:.|\n)*?>/gm, '').trim()
// normalize multiple spaces into one
contentExcerpt = contentExcerpt.replace(/ {2,}/g, ' ')
// decode common HTML entities
const entities = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
}
contentExcerpt = contentExcerpt.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, (m) => entities[m])
return contentExcerpt
},