mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-22 01:25:00 +02:00
UBER-769 Implement backlinks in server plugin (#3626)
Signed-off-by: Alexander Onnikov <alexander.onnikov@xored.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user