From a9d96d5d87b4137840a907cc7ede54bdd8529f2b Mon Sep 17 00:00:00 2001
From: Victor Ilyushchenko <alt13ri@gmail.com>
Date: Fri, 14 Feb 2025 12:39:46 +0300
Subject: [PATCH] Automatic link to ref conversion (#8006)

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
---
 plugins/text-editor-resources/package.json    |   3 +-
 .../src/components/extension/link.ts          | 165 +++++++++++++++++-
 .../src/kits/editor-kit.ts                    |   4 +-
 .../sanity/tests/documents/documents.spec.ts  |  29 +++
 .../model/documents/document-content-page.ts  |   4 +
 5 files changed, 198 insertions(+), 7 deletions(-)

diff --git a/plugins/text-editor-resources/package.json b/plugins/text-editor-resources/package.json
index 4ef3d4ad4d..d8f589ab21 100644
--- a/plugins/text-editor-resources/package.json
+++ b/plugins/text-editor-resources/package.json
@@ -88,6 +88,7 @@
     "@hcengineering/theme": "^0.6.5",
     "tippy.js": "~6.3.7",
     "@hcengineering/chunter": "^0.6.20",
-    "@tiptap/extension-text-align": "~2.11.0"
+    "@tiptap/extension-text-align": "~2.11.0",
+    "@hcengineering/workbench": "^0.6.16"
   }
 }
diff --git a/plugins/text-editor-resources/src/components/extension/link.ts b/plugins/text-editor-resources/src/components/extension/link.ts
index 4f67a800e8..388803b5c3 100644
--- a/plugins/text-editor-resources/src/components/extension/link.ts
+++ b/plugins/text-editor-resources/src/components/extension/link.ts
@@ -13,12 +13,18 @@
 // limitations under the License.
 //
 
-import { Extension } from '@tiptap/core'
-import { showPopup } from '@hcengineering/ui'
+import { type Class, type Doc, type Ref } from '@hcengineering/core'
+import { getMetadata, getResource } from '@hcengineering/platform'
+import presentation, { getClient } from '@hcengineering/presentation'
+import { parseLocation, showPopup } from '@hcengineering/ui'
+import view from '@hcengineering/view'
+import workbench, { type Application } from '@hcengineering/workbench'
+import { type Editor, Extension } from '@tiptap/core'
+import { Plugin, PluginKey } from '@tiptap/pm/state'
 import LinkPopup from '../LinkPopup.svelte'
 
-export const LinkShortcutsExtension = Extension.create<any>({
-  name: 'defaultShortcuts',
+export const LinkUtilsExtension = Extension.create<any>({
+  name: 'linkUtils',
 
   addKeyboardShortcuts () {
     return {
@@ -39,5 +45,156 @@ export const LinkShortcutsExtension = Extension.create<any>({
         return true
       }
     }
+  },
+
+  addProseMirrorPlugins () {
+    return [ResolveReferenceUrlsPlugin(this.editor)]
   }
 })
+
+export interface LinkToReferencePluginState {
+  references: Map<string, { id: Ref<Doc>, objectclass: Ref<Class<Doc>>, label: string }>
+  queue: Set<string>
+}
+
+const resolveReferencePluginKey = new PluginKey<LinkToReferencePluginState>('linkToReference')
+
+interface ReferenceProps {
+  id: Ref<Doc>
+  objectclass: Ref<Class<Doc>>
+  label: string
+}
+
+export function ResolveReferenceUrlsPlugin (editor: Editor): Plugin<LinkToReferencePluginState> {
+  return new Plugin<LinkToReferencePluginState>({
+    key: resolveReferencePluginKey,
+
+    appendTransaction: (transactions, oldState, newState) => {
+      if (transactions[0]?.getMeta('linkToReference') === undefined) return undefined
+      if (editor.schema.nodes.reference === undefined) return
+
+      const references = resolveReferencePluginKey.getState(newState)?.references ?? new Map()
+
+      const { tr } = newState
+      tr.doc.descendants((node, pos) => {
+        if (!node.isText || !node.marks.some((m) => m.type.name === 'link')) return
+
+        const url = node.textContent
+        const mapping = references.get(url)
+        if (mapping === undefined) return
+
+        const replacementNode = editor.schema.nodes.reference.create(mapping)
+        const mpos = tr.mapping.map(pos)
+        tr.replaceWith(mpos, mpos + node.nodeSize, replacementNode)
+      })
+
+      if (tr.steps.length > 0) return tr
+    },
+
+    state: {
+      init () {
+        return {
+          references: new Map(),
+          queue: new Set()
+        }
+      },
+      apply (tr, prev, oldState, newState) {
+        if (tr.getMeta('linkToReference') !== undefined) {
+          const references = tr.getMeta('linkToReference').references as LinkToReferencePluginState['references']
+          const urls = new Set(references.keys())
+          return {
+            queue: new Set(Array.from(prev.queue).filter((url) => !urls.has(url))),
+            references: new Map([...prev.references, ...references])
+          }
+        }
+
+        if (!tr.docChanged || oldState.doc.eq(newState.doc)) return prev
+
+        const urls: string[] = []
+        tr.doc.descendants((node) => {
+          if (!node.isText || !node.marks.some((m) => m.type.name === 'link')) return
+          const url = node.textContent
+
+          const hasNoMapping = prev.references.has(url) && prev.references.get(url) === undefined
+          if (prev.queue.has(url) || hasNoMapping) return
+
+          urls.push(url)
+        })
+
+        const promises = urls.map(async (url) => {
+          try {
+            return [url, await getReferenceFromUrl(url)] as const
+          } catch {
+            return [url, undefined] as const
+          }
+        })
+
+        if (promises.length > 0) {
+          void Promise.all(promises).then((references) => {
+            editor.view.dispatch(editor.state.tr.setMeta('linkToReference', { references: new Map(references) }))
+          })
+        }
+
+        return {
+          references: prev.references,
+          queue: new Set([...prev.queue, ...urls])
+        }
+      }
+    }
+  })
+}
+
+async function getReferenceFromUrl (text: string): Promise<ReferenceProps | undefined> {
+  const client = getClient()
+  const hierarchy = client.getHierarchy()
+
+  const url = new URL(text)
+
+  const frontUrl = getMetadata(presentation.metadata.FrontUrl) ?? window.location.origin
+  if (url.origin !== frontUrl) return
+
+  const location = parseLocation(url)
+
+  const appAlias = (location.path[2] ?? '').trim()
+  if (!(appAlias.length > 0)) return
+
+  const excludedApps = getMetadata(workbench.metadata.ExcludedApplications) ?? []
+  const apps: Application[] = client
+    .getModel()
+    .findAllSync<Application>(workbench.class.Application, { hidden: false, _id: { $nin: excludedApps } })
+
+  const app = apps.find((p) => p.alias === appAlias)
+
+  if (app?.locationResolver === undefined) return
+  const locationResolverFn = await getResource(app.locationResolver)
+  const resolvedLocation = await locationResolverFn(location)
+
+  const locationParts = decodeURIComponent(resolvedLocation?.loc?.fragment ?? '').split('|')
+  const id = locationParts[1] as Ref<Doc>
+  const objectclass = locationParts[2] as Ref<Class<Doc>>
+  if (id === undefined || objectclass === undefined) return
+
+  const linkProviders = client.getModel().findAllSync(view.mixin.LinkIdProvider, {})
+  const linkProvider = linkProviders.find(({ _id }) => hierarchy.isDerived(objectclass, _id))
+  const _id: Ref<Doc> | undefined =
+    linkProvider !== undefined ? (await (await getResource(linkProvider.decode))(id)) ?? id : id
+
+  let label = ''
+  const labelProvider = hierarchy.classHierarchyMixin(objectclass, view.mixin.ObjectIdentifier)
+  if (labelProvider !== undefined) {
+    const resource = await getResource(labelProvider.provider)
+    label = await resource(client, _id)
+  } else {
+    const titleMixin = hierarchy.classHierarchyMixin(objectclass, view.mixin.ObjectTitle)
+    if (titleMixin === undefined) return
+
+    const titleProviderFn = await getResource(titleMixin.titleProvider)
+    label = await titleProviderFn(client, _id)
+  }
+
+  return {
+    id: _id,
+    objectclass,
+    label
+  }
+}
diff --git a/plugins/text-editor-resources/src/kits/editor-kit.ts b/plugins/text-editor-resources/src/kits/editor-kit.ts
index f59213e70f..9510daeb78 100644
--- a/plugins/text-editor-resources/src/kits/editor-kit.ts
+++ b/plugins/text-editor-resources/src/kits/editor-kit.ts
@@ -46,7 +46,7 @@ import { MermaidExtension, type MermaidOptions, mermaidOptions } from '../compon
 import { DrawingBoardExtension, type DrawingBoardOptions } from '../components/extension/drawingBoard'
 import { type IndendOptions, IndentExtension, indentExtensionOptions } from '../components/extension/indent'
 import TextAlign, { type TextAlignOptions } from '@tiptap/extension-text-align'
-import { LinkShortcutsExtension } from '../components/extension/link'
+import { LinkUtilsExtension } from '../components/extension/link'
 
 export interface EditorKitOptions extends DefaultKitOptions {
   history?: false
@@ -316,7 +316,7 @@ async function buildEditorKit (): Promise<Extension<EditorKitOptions, any>> {
                 ])
               }
 
-              staticKitExtensions.push([950, LinkShortcutsExtension.configure({})])
+              staticKitExtensions.push([950, LinkUtilsExtension.configure({})])
 
               if (mode !== 'compact' && this.options.note !== false) {
                 staticKitExtensions.push([1000, NoteExtension.configure(this.options.note ?? {})])
diff --git a/tests/sanity/tests/documents/documents.spec.ts b/tests/sanity/tests/documents/documents.spec.ts
index 37ed9b4d03..ddb93513d3 100644
--- a/tests/sanity/tests/documents/documents.spec.ts
+++ b/tests/sanity/tests/documents/documents.spec.ts
@@ -194,6 +194,35 @@ test.describe('Documents tests', () => {
     await documentContentPage.checkLinkInTheText(contentLink, 'http://test/link/123456')
   })
 
+  test('Check Link to Reference conversion', async () => {
+    const sourceDocument: NewDocument = {
+      title: `Reference Document Title-${generateId()}`,
+      space: 'Default'
+    }
+    const targetDocument: NewDocument = {
+      title: `Reference Document Title-${generateId()}`,
+      space: 'Default'
+    }
+
+    await leftSideMenuPage.clickDocuments()
+
+    await documentsPage.clickOnButtonCreateDocument()
+    await documentsPage.createDocument(targetDocument)
+    await documentsPage.openDocument(targetDocument.title)
+    await documentContentPage.checkDocumentTitle(targetDocument.title)
+    const targetDocumentUrl = documentsPage.page.url()
+
+    await documentsPage.clickOnButtonCreateDocument()
+    await documentsPage.createDocument(sourceDocument)
+    await documentsPage.openDocument(sourceDocument.title)
+    await documentContentPage.checkDocumentTitle(sourceDocument.title)
+
+    await documentContentPage.addRandomLines(5)
+    await documentContentPage.addContentToTheNewLine(targetDocumentUrl)
+    await documentContentPage.addRandomLines(5)
+    await documentContentPage.checkReferenceInTheText(targetDocument.title)
+  })
+
   test('Locked document and checking URL', async ({ page, context }) => {
     const newDocument: NewDocument = {
       title: `New Document-${generateId()}`,
diff --git a/tests/sanity/tests/model/documents/document-content-page.ts b/tests/sanity/tests/model/documents/document-content-page.ts
index 11c7cb237b..f454f0fb6b 100644
--- a/tests/sanity/tests/model/documents/document-content-page.ts
+++ b/tests/sanity/tests/model/documents/document-content-page.ts
@@ -286,6 +286,10 @@ export class DocumentContentPage extends CommonPage {
     await expect(this.page.locator('a', { hasText: text })).toHaveAttribute('href', link)
   }
 
+  async checkReferenceInTheText (label: string): Promise<void> {
+    await expect(this.page.locator('span', { hasText: '@' + label })).toHaveAttribute('data-type', 'reference')
+  }
+
   async executeMoreAction (action: string): Promise<void> {
     await this.buttonMoreActions().click()
     await this.selectFromDropdown(this.page, action)