UBER-769 Implement backlinks in server plugin (#3626)

Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
Alexander Onnikov
2023-08-24 22:53:18 +07:00
committed by GitHub
parent 821ed6cd7b
commit 2c585b2ecd
41 changed files with 813 additions and 132 deletions
@@ -6,6 +6,7 @@
"license": "EPL-2.0",
"scripts": {
"build": "heft build",
"test": "heft test",
"build:watch": "tsc",
"lint:fix": "eslint --fix src",
"lint": "eslint src",
@@ -36,6 +37,8 @@
"@hcengineering/workbench": "^0.6.8",
"@hcengineering/notification": "^0.6.14",
"@hcengineering/server-notification": "^0.6.1",
"@hcengineering/server-notification-resources": "^0.6.0"
"@hcengineering/server-notification-resources": "^0.6.0",
"@hcengineering/server": "^0.6.4",
"@hcengineering/text": "^0.6.0"
}
}
@@ -0,0 +1,72 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Class, Doc, Ref } from '@hcengineering/core'
import { getBacklinks } from '../backlinks'
describe('extractBacklinks', () => {
it('should return no backlinks for empty document', () => {
const content = '<p></p>'
const backlinks = getBacklinks(
'backlinkId' as Ref<Doc>,
'backlinkClass' as Ref<Class<Doc>>,
'attachedDocId' as Ref<Doc>,
content
)
expect(backlinks).toEqual([])
})
it('should parse single backlink', () => {
const content =
'<p>hello <span class="reference" data-type="reference" data-id="id" data-objectclass="contact:class:Person" data-label="Appleseed John">@Appleseed John</span> </p>'
const backlinks = getBacklinks(
'backlinkId' as Ref<Doc>,
'backlinkClass' as Ref<Class<Doc>>,
'attachedDocId' as Ref<Doc>,
content
)
expect(backlinks.length).toBe(1)
expect(backlinks).toEqual([
{
attachedTo: 'id',
attachedToClass: 'contact:class:Person',
collection: 'backlinks',
backlinkId: 'backlinkId',
backlinkClass: 'backlinkClass',
message:
'hello <span data-type="reference" data-id="id" data-objectclass="contact:class:Person" data-label="Appleseed John" class="reference">@Appleseed John</span>',
attachedDocId: 'attachedDocId'
}
])
})
it('should parse single backlink for multiple references', () => {
const content =
'<p><span class="reference" data-type="reference" data-id="id" data-label="Appleseed John" data-objectclass="contact:class:Person">@Appleseed John</span> <span data-type="reference" class="reference" data-id="id" data-label="Appleseed John" data-objectclass="contact:class:Person">@Appleseed John</span> </p>'
const backlinks = getBacklinks(
'backlinkId' as Ref<Doc>,
'backlinkClass' as Ref<Class<Doc>>,
'attachedDocId' as Ref<Doc>,
content
)
expect(backlinks.length).toBe(1)
})
})
@@ -0,0 +1,54 @@
//
// Copyright © 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { Backlink } from '@hcengineering/chunter'
import { Class, Data, Doc, Ref } from '@hcengineering/core'
import { defaultExtensions, getHTML, parseHTML, ReferenceNode } from '@hcengineering/text'
const extensions = [...defaultExtensions, ReferenceNode]
export function getBacklinks (
backlinkId: Ref<Doc>,
backlinkClass: Ref<Class<Doc>>,
attachedDocId: Ref<Doc> | undefined,
content: string
): Array<Data<Backlink>> {
const doc = parseHTML(content, extensions)
const result: Array<Data<Backlink>> = []
doc.descendants((node, _pos, parent): boolean => {
if (node.type.name === ReferenceNode.name) {
const ato = node.attrs.id as Ref<Doc>
const atoClass = node.attrs.objectClass as Ref<Class<Doc>>
const e = result.find((e) => e.attachedTo === ato && e.attachedToClass === atoClass)
if (e === undefined && ato !== attachedDocId && ato !== backlinkId) {
result.push({
attachedTo: ato,
attachedToClass: atoClass,
collection: 'backlinks',
backlinkId,
backlinkClass,
message: parent !== null ? getHTML(parent, extensions) : '',
attachedDocId
})
}
}
return true
})
return result
}
+192 -1
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2022 Hardcore Engineering Inc.
// Copyright © 2022, 2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
@@ -29,6 +29,7 @@ import core, {
TxCollectionCUD,
TxCreateDoc,
TxCUD,
TxFactory,
TxProcessor,
TxRemoveDoc,
TxUpdateDoc
@@ -38,6 +39,130 @@ import { getMetadata } from '@hcengineering/platform'
import serverCore, { TriggerControl } from '@hcengineering/server-core'
import { getDocCollaborators, getMixinTx, pushNotification } from '@hcengineering/server-notification-resources'
import { workbenchId } from '@hcengineering/workbench'
import { getBacklinks } from './backlinks'
function getCreateBacklinksTxes (control: TriggerControl, txFactory: TxFactory, doc: Doc): Tx[] {
const txes: Tx[] = []
const backlinkId = doc._id
const backlinkClass = doc._class
const attachedDocId = doc._id
const attributes = control.hierarchy.getAllAttributes(doc._class)
for (const attr of attributes.values()) {
if (attr.type._class === core.class.TypeMarkup) {
const content = (doc as any)[attr.name]?.toString() ?? ''
const backlinks = getBacklinks(backlinkId, backlinkClass, attachedDocId, content)
for (const backlink of backlinks) {
const innerTx = txFactory.createTxCreateDoc(chunter.class.Backlink, chunter.space.Backlinks, backlink)
const collectionTx = txFactory.createTxCollectionCUD(
backlink.attachedToClass,
backlink.attachedTo,
chunter.space.Backlinks,
backlink.collection,
innerTx
)
txes.push(collectionTx)
}
}
}
return txes
}
async function getUpdateBacklinksTxes (control: TriggerControl, txFactory: TxFactory, doc: Doc): Promise<Tx[]> {
const txes: Tx[] = []
const backlinkId = doc._id
const backlinkClass = doc._class
const attachedDocId = doc._id
const attributes = control.hierarchy.getAllAttributes(doc._class)
for (const attr of attributes.values()) {
if (attr.type._class === core.class.TypeMarkup) {
const content = (doc as any)[attr.name]?.toString() ?? ''
const backlinks = getBacklinks(backlinkId, backlinkClass, attachedDocId, content)
const current = await control.findAll(chunter.class.Backlink, {
backlinkId,
backlinkClass,
attachedDocId,
collection: 'backlinks'
})
for (const c of current) {
// Find existing and check if we need to update message
const pos = backlinks.findIndex(
(b) => b.backlinkId === c.backlinkId && b.backlinkClass === c.backlinkClass && b.attachedTo === c.attachedTo
)
if (pos !== -1) {
// Update existing backlinks when message changed
const data = backlinks[pos]
if (c.message !== data.message) {
const innerTx = txFactory.createTxUpdateDoc(c._class, c.space, c._id, {
message: data.message
})
txes.push(
txFactory.createTxCollectionCUD(
c.attachedToClass,
c.attachedTo,
chunter.space.Backlinks,
c.collection,
innerTx
)
)
}
backlinks.splice(pos, 1)
} else {
// Remove not found backlinks
const innerTx = txFactory.createTxRemoveDoc(c._class, c.space, c._id)
txes.push(
txFactory.createTxCollectionCUD(
c.attachedToClass,
c.attachedTo,
chunter.space.Backlinks,
c.collection,
innerTx
)
)
}
}
// Add missing backlinks
for (const backlink of backlinks) {
const backlinkTx = txFactory.createTxCreateDoc(chunter.class.Backlink, chunter.space.Backlinks, backlink)
txes.push(
txFactory.createTxCollectionCUD(
backlink.attachedToClass,
backlink.attachedTo,
chunter.space.Backlinks,
backlink.collection,
backlinkTx
)
)
}
}
}
return txes
}
async function getRemoveBacklinksTxes (control: TriggerControl, txFactory: TxFactory, doc: Ref<Doc>): Promise<Tx[]> {
const txes: Tx[] = []
const backlinks = await control.findAll(chunter.class.Backlink, { attachedDocId: doc, collection: 'backlinks' })
for (const b of backlinks) {
const innerTx = txFactory.createTxRemoveDoc(b._class, b.space, b._id)
txes.push(
txFactory.createTxCollectionCUD(b.attachedToClass, b.attachedTo, chunter.space.Backlinks, b.collection, innerTx)
)
}
return txes
}
/**
* @public
@@ -193,6 +318,62 @@ async function ThreadMessageDelete (tx: Tx, control: TriggerControl): Promise<Tx
return [updateTx]
}
async function BacklinksCreate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const hierarchy = control.hierarchy
const ctx = TxProcessor.extractTx(tx) as TxCreateDoc<Doc>
if (ctx._class !== core.class.TxCreateDoc) return []
if (hierarchy.isDerived(ctx.objectClass, chunter.class.Backlink)) return []
const txFactory = new TxFactory(control.txFactory.account)
const doc = TxProcessor.createDoc2Doc(ctx)
const txes: Tx[] = getCreateBacklinksTxes(control, txFactory, doc)
if (txes.length !== 0) {
await control.apply(txes, true)
}
return []
}
async function BacklinksUpdate (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const hierarchy = control.hierarchy
const ctx = TxProcessor.extractTx(tx) as TxUpdateDoc<Doc>
if (ctx._class !== core.class.TxUpdateDoc) return []
if (hierarchy.isDerived(ctx.objectClass, chunter.class.Backlink)) return []
const rawDoc = (await control.findAll(ctx.objectClass, { _id: ctx.objectId }))[0]
if (rawDoc === undefined) return []
const txFactory = new TxFactory(control.txFactory.account)
if (rawDoc !== undefined) {
const doc = TxProcessor.updateDoc2Doc(rawDoc, ctx)
const txes: Tx[] = await getUpdateBacklinksTxes(control, txFactory, doc)
if (txes.length !== 0) {
await control.apply(txes, true)
}
}
return []
}
async function BacklinksRemove (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const hierarchy = control.hierarchy
const ctx = TxProcessor.extractTx(tx) as TxRemoveDoc<Doc>
if (ctx._class !== core.class.TxRemoveDoc) return []
if (hierarchy.isDerived(ctx.objectClass, chunter.class.Backlink)) return []
const txFactory = new TxFactory(control.txFactory.account)
const txes: Tx[] = await getRemoveBacklinksTxes(control, txFactory, ctx.objectId)
if (txes.length !== 0) {
await control.apply(txes, true)
}
return []
}
/**
* @public
*/
@@ -255,6 +436,15 @@ export async function OnMessageSent (tx: Tx, control: TriggerControl): Promise<T
return res
}
/**
* @public
*/
export async function BacklinkTrigger (tx: Tx, control: TriggerControl): Promise<Tx[]> {
const promises = [BacklinksCreate(tx, control), BacklinksUpdate(tx, control), BacklinksRemove(tx, control)]
const res = await Promise.all(promises)
return res.flat()
}
/**
* @public
*/
@@ -321,6 +511,7 @@ export async function IsChannelMessage (
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export default async () => ({
trigger: {
BacklinkTrigger,
ChunterTrigger,
OnMessageSent
},
+1
View File
@@ -29,6 +29,7 @@ export const serverChunterId = 'server-chunter' as Plugin
*/
export default plugin(serverChunterId, {
trigger: {
BacklinkTrigger: '' as Resource<TriggerFunc>,
ChunterTrigger: '' as Resource<TriggerFunc>,
OnMessageSent: '' as Resource<TriggerFunc>
},