Merge remote-tracking branch 'origin/develop' into staging

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev
2025-01-15 23:42:46 +07:00
92 changed files with 2145 additions and 616 deletions
+1 -1
View File
@@ -202,7 +202,7 @@
"name": "docker:build",
"phases": ["_phase:build", "_phase:bundle", "_phase:package", "_phase:docker-build"],
"enableParallelism": true,
"incremental": true
"incremental": true
},
{
"summary": "docker:rebuild",
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 801 KiB

After

Width:  |  Height:  |  Size: 655 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 801 KiB

After

Width:  |  Height:  |  Size: 655 KiB

+12 -3
View File
@@ -1,7 +1,7 @@
import login, { loginId } from '@hcengineering/login'
import { getEmbeddedLabel, getMetadata, setMetadata } from '@hcengineering/platform'
import presentation, { closeClient, MessageBox, setDownloadProgress } from '@hcengineering/presentation'
import { settingId } from '@hcengineering/setting'
import settings, { settingId } from '@hcengineering/setting'
import {
closePanel,
closePopup,
@@ -24,7 +24,6 @@ import { isOwnerOrMaintainer } from '@hcengineering/core'
import { configurePlatform } from './platform'
import { defineScreenShare } from './screenShare'
import { IPCMainExposed } from './types'
import settings from '@hcengineering/setting'
defineScreenShare()
@@ -108,7 +107,17 @@ window.addEventListener('DOMContentLoaded', () => {
const workspace = getMetadata(presentation.metadata.WorkspaceId)
if (isOwnerOrMaintainer()) {
if (token != null && endpoint != null && workspace != null) {
ipcMain.startBackup(token, endpoint, workspace)
// ipcMain.startBackup(token, endpoint, workspace)
closePopup()
closePanel()
const loc = getCurrentResolvedLocation()
loc.fragment = undefined
loc.query = undefined
loc.path[2] = settingId
loc.path[3] = 'setting'
loc.path[4] = 'backup'
loc.path.length = 5
navigate(loc)
}
} else {
showPopup(MessageBox, {
+3 -2
View File
@@ -40,7 +40,7 @@ import { taskId } from '@hcengineering/task'
import telegram, { telegramId } from '@hcengineering/telegram'
import { templatesId } from '@hcengineering/templates'
import tracker, { trackerId } from '@hcengineering/tracker'
import uiPlugin, { getCurrentLocation, locationStorageKeyId, locationToUrl, navigate, parseLocation, setLocationStorageKey } from '@hcengineering/ui'
import uiPlugin, { getCurrentLocation, locationStorageKeyId, navigate, setLocationStorageKey } from '@hcengineering/ui'
import { uploaderId } from '@hcengineering/uploader'
import { viewId } from '@hcengineering/view'
import workbench, { workbenchId } from '@hcengineering/workbench'
@@ -215,6 +215,7 @@ export async function configurePlatform (): Promise<void> {
setMetadata(presentation.metadata.PreviewConfig, parsePreviewConfig(config.PREVIEW_CONFIG))
setMetadata(presentation.metadata.UploadConfig, parseUploadConfig(config.UPLOAD_CONFIG, config.UPLOAD_URL))
setMetadata(presentation.metadata.FrontUrl, config.FRONT_URL)
setMetadata(presentation.metadata.LinkPreviewUrl, config.LINK_PREVIEW_URL ?? '')
setMetadata(presentation.metadata.StatsUrl, config.STATS_URL)
setMetadata(textEditor.metadata.Collaborator, config.COLLABORATOR ?? '')
@@ -238,7 +239,7 @@ export async function configurePlatform (): Promise<void> {
setMetadata(notification.metadata.PushPublicKey, config.PUSH_PUBLIC_KEY)
setMetadata(rekoni.metadata.RekoniUrl, config.REKONI_URL)
setMetadata(contactPlugin.metadata.LastNameFirst, myBranding.lastNameFirst === 'true' ?? false)
setMetadata(contactPlugin.metadata.LastNameFirst, myBranding.lastNameFirst === 'true')
setMetadata(love.metadata.ServiceEnpdoint, config.LOVE_ENDPOINT)
setMetadata(love.metadata.WebSocketURL, config.LIVEKIT_WS)
setMetadata(print.metadata.PrintURL, config.PRINT_URL)
+23 -23
View File
@@ -5,39 +5,39 @@ import { ScreenSource } from '@hcengineering/love'
*/
export interface Config {
ACCOUNTS_URL: string
AI_URL?: string
ANALYTICS_COLLECTOR_URL?: string
BRANDING_URL?: string
CALENDAR_URL: string
COLLABORATOR?: string
COLLABORATOR_URL: string
FRONT_URL: string
CONFIG_URL: string
DESKTOP_UPDATES_CHANNEL?: string
DESKTOP_UPDATES_URL?: string
DISABLE_SIGNUP?: string
FILES_URL: string
UPLOAD_URL: string
MODEL_VERSION?: string
VERSION?: string
TELEGRAM_URL: string
GMAIL_URL: string
CALENDAR_URL: string
REKONI_URL: string
INITIAL_URL: string
FRONT_URL: string
GITHUB_APP: string
GITHUB_CLIENTID: string
GITHUB_URL: string
CONFIG_URL: string
LOVE_ENDPOINT?: string
GMAIL_URL: string
INITIAL_URL: string
LINK_PREVIEW_URL?: string
LIVEKIT_WS?: string
SIGN_URL?: string
LOVE_ENDPOINT?: string
MODEL_VERSION?: string
PRESENCE_URL?: string
PREVIEW_CONFIG: string
PRINT_URL?: string
PUSH_PUBLIC_KEY: string
ANALYTICS_COLLECTOR_URL?: string
AI_URL?:string
DISABLE_SIGNUP?: string
BRANDING_URL?: string
PREVIEW_CONFIG: string
UPLOAD_CONFIG: string
DESKTOP_UPDATES_URL?: string
DESKTOP_UPDATES_CHANNEL?: string
TELEGRAM_BOT_URL?: string
PRESENCE_URL?: string
REKONI_URL: string
SIGN_URL?: string
STATS_URL?: string
TELEGRAM_BOT_URL?: string
TELEGRAM_URL: string
UPLOAD_CONFIG: string
UPLOAD_URL: string
VERSION?: string
}
export interface Branding {
+2 -1
View File
@@ -8,5 +8,6 @@
"REKONI_URL": "https://rekoni.hc.engineering",
"COLLABORATOR_URL": "wss://collaborator.hc.engineering",
"STATS_URL": "https://stats.hc.engineering",
"PRESENCE_URL": "wss://presence.hc.engineering"
"PRESENCE_URL": "wss://presence.hc.engineering",
"BACKUP_URL": "https://front.hc.engineering/api/backup"
}
+8 -4
View File
@@ -1,5 +1,5 @@
//
// Copyright © 2022, 2023 Hardcore Engineering Inc.
// Copyright © 2022, 2023, 2025 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
@@ -45,7 +45,7 @@ import { questionsId } from '@hcengineering/questions'
import { recruitId } from '@hcengineering/recruit'
import rekoni from '@hcengineering/rekoni'
import { requestId } from '@hcengineering/request'
import { settingId } from '@hcengineering/setting'
import setting, { settingId } from '@hcengineering/setting'
import sign from '@hcengineering/sign'
import { supportId } from '@hcengineering/support'
import { tagsId } from '@hcengineering/tags'
@@ -157,6 +157,7 @@ export interface Config {
TELEGRAM_BOT_URL?: string
AI_URL?:string
DISABLE_SIGNUP?: string
LINK_PREVIEW_URL?: string
// Could be defined for dev environment
FRONT_URL?: string
PREVIEW_CONFIG?: string
@@ -165,6 +166,7 @@ export interface Config {
PRESENCE_URL?: string
USE_BINARY_PROTOCOL?: boolean,
TRANSACTOR_OVERRIDE?: string
BACKUP_URL?: string
}
export interface Branding {
@@ -313,7 +315,7 @@ export async function configurePlatform() {
setMetadata(presentation.metadata.PreviewConfig, parsePreviewConfig(config.PREVIEW_CONFIG))
setMetadata(presentation.metadata.UploadConfig, parseUploadConfig(config.UPLOAD_CONFIG, config.UPLOAD_URL))
setMetadata(presentation.metadata.StatsUrl, config.STATS_URL)
setMetadata(presentation.metadata.LinkPreviewUrl, config.LINK_PREVIEW_URL)
setMetadata(textEditor.metadata.Collaborator, config.COLLABORATOR)
if (config.MODEL_VERSION != null) {
@@ -339,7 +341,7 @@ export async function configurePlatform() {
setMetadata(rekoni.metadata.RekoniUrl, config.REKONI_URL)
setMetadata(uiPlugin.metadata.DefaultApplication, login.component.LoginApp)
setMetadata(contactPlugin.metadata.LastNameFirst, myBranding.lastNameFirst === 'true' ?? false)
setMetadata(contactPlugin.metadata.LastNameFirst, myBranding.lastNameFirst === 'true')
setMetadata(love.metadata.ServiceEnpdoint, config.LOVE_ENDPOINT)
setMetadata(love.metadata.WebSocketURL, config.LIVEKIT_WS)
setMetadata(print.metadata.PrintURL, config.PRINT_URL)
@@ -433,5 +435,7 @@ export async function configurePlatform() {
setMetadata(workbench.metadata.DefaultSpace, myBranding.defaultSpace ?? tracker.project.DefaultProject)
setMetadata(workbench.metadata.DefaultSpecial, myBranding.defaultSpecial ?? 'issues')
setMetadata(setting.metadata.BackupUrl, config.BACKUP_URL ?? '')
initThemeStore()
}
+2 -1
View File
@@ -58,6 +58,7 @@
"@hcengineering/chunter": "^0.6.20",
"@hcengineering/text-editor": "^0.6.0",
"@hcengineering/collaboration": "^0.6.0",
"@hcengineering/time": "^0.6.0"
"@hcengineering/time": "^0.6.0",
"@hcengineering/rank": "^0.6.4"
}
}
+49 -6
View File
@@ -4,12 +4,12 @@
import attachment, { type Attachment } from '@hcengineering/attachment'
import {
YXmlElement,
YXmlText,
YAbstractType,
yXmlElementClone,
loadCollabYdoc,
saveCollabYdoc
saveCollabYdoc,
YAbstractType,
YXmlElement,
yXmlElementClone,
YXmlText
} from '@hcengineering/collaboration'
import {
type ChangeControl,
@@ -17,8 +17,10 @@ import {
createChangeControl,
createDocumentTemplate,
type DocumentCategory,
type DocumentMeta,
documentsId,
DocumentState
DocumentState,
type ProjectMeta
} from '@hcengineering/controlled-documents'
import {
type Class,
@@ -48,6 +50,7 @@ import { DOMAIN_ATTACHMENT } from '@hcengineering/model-attachment'
import core from '@hcengineering/model-core'
import tags from '@hcengineering/tags'
import { makeRank } from '@hcengineering/rank'
import documents, { DOMAIN_DOCUMENTS } from './index'
async function createTemplatesSpace (tx: TxOperations): Promise<void> {
@@ -364,6 +367,42 @@ async function migrateDocSections (client: MigrationClient): Promise<void> {
}
}
async function migrateProjectMetaRank (client: MigrationClient): Promise<void> {
const projectMeta = await client.find<ProjectMeta>(DOMAIN_DOCUMENTS, {
_class: documents.class.ProjectMeta,
rank: { $exists: false }
})
const docMeta = await client.find<DocumentMeta>(DOMAIN_DOCUMENTS, {
_class: documents.class.ProjectDocument,
_id: { $in: projectMeta.map((p) => p.meta) }
})
const docMetaById = new Map<Ref<DocumentMeta>, DocumentMeta>()
for (const doc of docMeta) {
docMetaById.set(doc._id, doc)
}
projectMeta.sort((a, b) => {
const docA = docMetaById.get(a.meta)
const docB = docMetaById.get(b.meta)
return (docA?.title ?? '').localeCompare(docB?.title ?? '', undefined, { numeric: true })
})
let rank = makeRank(undefined, undefined)
const operations: { filter: MigrationDocumentQuery<ProjectMeta>, update: MigrateUpdate<ProjectMeta> }[] = []
for (const doc of projectMeta) {
operations.push({
filter: { _id: doc._id },
update: { $set: { rank } }
})
rank = makeRank(rank, undefined)
}
await client.bulk(DOMAIN_DOCUMENTS, operations)
}
export const documentsOperation: MigrateOperation = {
async migrate (client: MigrationClient): Promise<void> {
await tryMigrate(client, documentsId, [
@@ -374,6 +413,10 @@ export const documentsOperation: MigrateOperation = {
{
state: 'migrateDocSections',
func: migrateDocSections
},
{
state: 'migrateProjectMetaRank',
func: migrateProjectMetaRank
}
])
},
+6 -1
View File
@@ -58,7 +58,8 @@ import {
type Role,
type TypedSpace,
type Account,
type RolesAssignment
type RolesAssignment,
type Rank
} from '@hcengineering/core'
import {
ArrOf,
@@ -181,6 +182,10 @@ export class TProjectMeta extends TDoc implements ProjectMeta {
@Prop(Collection(documents.class.ProjectDocument), documents.string.Documents)
documents!: CollectionSize<ProjectDocument>
@Index(IndexKind.Indexed)
@Hidden()
rank!: Rank
}
@Model(documents.class.ProjectDocument, core.class.AttachedDoc, DOMAIN_DOCUMENTS)
+13
View File
@@ -226,6 +226,19 @@ export function createModel (builder: Builder): void {
},
setting.ids.General
)
builder.createDoc(
setting.class.WorkspaceSettingCategory,
core.space.Model,
{
name: 'backup',
label: setting.string.Backup,
icon: setting.icon.Setting,
component: setting.component.Backup,
order: 950,
role: AccountRole.Owner
},
setting.ids.Backup
)
builder.createDoc(
setting.class.WorkspaceSettingCategory,
core.space.Model,
+2 -2
View File
@@ -28,8 +28,8 @@ import { fillConfiguration, pluginFilterTx } from '../utils'
import { connect } from './connection'
import { genMinModel } from './minmodel'
function filterPlugin (plugin: Plugin): (txes: Tx[]) => Promise<Tx[]> {
return async (txes) => {
function filterPlugin (plugin: Plugin): (txes: Tx[]) => Tx[] {
return (txes) => {
const configs = new Map<Ref<PluginConfiguration>, PluginConfiguration>()
fillConfiguration(txes, configs)
+21 -12
View File
@@ -215,7 +215,7 @@ export interface TxPersistenceStore {
store: (model: LoadModelResponse) => Promise<void>
}
export type ModelFilter = (tx: Tx[]) => Promise<Tx[]>
export type ModelFilter = (tx: Tx[]) => Tx[]
/**
* @public
@@ -256,17 +256,21 @@ export async function createClient (
}
const conn = await ctx.with('connect', {}, () => connect(txHandler))
const { mode, current, addition } = await ctx.with('load-model', {}, (ctx) => loadModel(ctx, conn, txPersistence))
let { mode, current, addition } = await ctx.with('load-model', {}, (ctx) => loadModel(ctx, conn, txPersistence))
switch (mode) {
case 'same':
case 'upgrade':
await ctx.with('build-model', {}, (ctx) => buildModel(ctx, current, modelFilter, hierarchy, model))
ctx.withSync('build-model', {}, (ctx) => {
buildModel(ctx, current, modelFilter, hierarchy, model)
})
break
case 'addition':
await ctx.with('build-model', {}, (ctx) =>
ctx.withSync('build-model', {}, (ctx) => {
buildModel(ctx, current.concat(addition), modelFilter, hierarchy, model)
)
})
}
current = []
addition = []
txBuffer = txBuffer.filter((tx) => tx.space !== core.space.Model)
@@ -287,7 +291,7 @@ export async function createClient (
return
}
// Find all new transactions and apply
const { mode, current, addition } = await ctx.with('load-model', {}, (ctx) => loadModel(ctx, conn, txPersistence))
let { mode, current, addition } = await ctx.with('load-model', {}, (ctx) => loadModel(ctx, conn, txPersistence))
switch (mode) {
case 'upgrade':
@@ -296,16 +300,21 @@ export async function createClient (
model = new ModelDb(hierarchy)
;(client as ClientImpl).setModel(hierarchy, model)
await ctx.with('build-model', {}, (ctx) => buildModel(ctx, current, modelFilter, hierarchy, model))
ctx.withSync('build-model', {}, (ctx) => {
buildModel(ctx, current, modelFilter, hierarchy, model)
})
current = []
await oldOnConnect?.(ClientConnectEvent.Upgraded, _lastTx, data)
// No need to fetch more stuff since upgrade was happened.
break
case 'addition':
await ctx.with('build-model', {}, (ctx) =>
ctx.withSync('build-model', {}, (ctx) => {
buildModel(ctx, current.concat(addition), modelFilter, hierarchy, model)
)
})
break
}
current = []
addition = []
if (lastTx === undefined) {
// No need to do anything here since we connected.
@@ -391,13 +400,13 @@ async function loadModel (
return { mode: 'addition', current: current.transactions, addition: result.transactions }
}
async function buildModel (
function buildModel (
ctx: MeasureContext,
transactions: Tx[],
modelFilter: ModelFilter | undefined,
hierarchy: Hierarchy,
model: ModelDb
): Promise<void> {
): void {
const systemTx: Tx[] = []
const userTx: Tx[] = []
@@ -416,7 +425,7 @@ async function buildModel (
let txes = systemTx.concat(userTx)
if (modelFilter !== undefined) {
txes = await modelFilter(txes)
txes = modelFilter(txes)
}
ctx.withSync('build hierarchy', {}, () => {
@@ -186,6 +186,8 @@
{/each}
{/if}
</th>
{:else if node.type === MarkupNodeType.mermaid}
<!-- TODO -->
{:else if node.type === MarkupNodeType.comment}
<!-- Ignore -->
{:else}
-2
View File
@@ -12,11 +12,9 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
export interface DrawingData {
content?: string
}
export interface DrawingProps {
readonly: boolean
autoSize?: boolean
+12 -2
View File
@@ -200,7 +200,7 @@ export async function deleteFile (id: string): Promise<void> {
}
})
if (resp.status !== 200) {
if (!resp.ok) {
throw new Error('Failed to delete file')
}
}
@@ -217,7 +217,7 @@ async function uploadFileWithFormData (file: File, uuid: string, uploadUrl: stri
body: data
})
if (resp.status !== 200) {
if (!resp.ok) {
if (resp.status === 413) {
throw new PlatformError(new Status(Severity.ERROR, plugin.status.FileTooLarge, {}))
} else {
@@ -284,3 +284,13 @@ async function uploadFileWithSignedUrl (file: File, uuid: string, uploadUrl: str
})
}
}
export async function getJsonOrEmpty (file: string, name: string): Promise<any> {
try {
const fileUrl = getFileUrl(file, name)
const resp = await fetch(fileUrl)
return await resp.json()
} catch {
return {}
}
}
+1
View File
@@ -69,3 +69,4 @@ export * from './preview'
export * from './sound'
export * from './stats'
export * from './drawing'
export * from './link-preview'
+60
View File
@@ -0,0 +1,60 @@
//
// Copyright © 2025 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 { getMetadata } from '@hcengineering/platform'
import plugin from './plugin'
export function isLinkPreviewEnabled (): boolean {
return getMetadata(plugin.metadata.LinkPreviewUrl) !== undefined
}
export interface LinkPreviewDetails {
title?: string
description?: string
url?: string
icon?: string
image?: string
charset?: string
hostname?: string
host?: string
}
export function canDisplayLinkPreview (val: LinkPreviewDetails): boolean {
if (val.hostname === undefined) {
return false
}
if (val.image === undefined && val.description === undefined) {
return false
}
if (val.title === undefined && val.description === undefined) {
return false
}
return true
}
export async function fetchLinkPreviewDetails (url: string, timeoutMs = 15000): Promise<LinkPreviewDetails> {
try {
const linkPreviewUrl = getMetadata(plugin.metadata.LinkPreviewUrl)
let token: string = ''
if (getMetadata(plugin.metadata.Token) !== undefined) {
token = getMetadata(plugin.metadata.Token) as string
}
const response = await fetch(`${linkPreviewUrl}/details?q=${url}`, {
headers: { Authorization: 'Bearer ' + token },
signal: AbortSignal.timeout(timeoutMs)
})
return response.json() as LinkPreviewDetails
} catch {
return {}
}
}
+1
View File
@@ -144,6 +144,7 @@ export default plugin(presentationId, {
Workspace: '' as Metadata<string>,
WorkspaceId: '' as Metadata<string>,
FrontUrl: '' as Asset,
LinkPreviewUrl: '' as Metadata<string>,
UploadConfig: '' as Metadata<UploadConfig>,
PreviewConfig: '' as Metadata<PreviewConfig | undefined>,
ClientHook: '' as Metadata<ClientHook>,
+2
View File
@@ -33,6 +33,7 @@ import { DefaultKit, DefaultKitOptions } from './default-kit'
import { CodeExtension, codeOptions } from '../marks/code'
import { NoteBaseExtension } from '../marks/noteBase'
import { CommentNode } from '../nodes/comment'
import { MermaidExtension, mermaidOptions } from '../nodes/mermaid'
import TextAlign from '@tiptap/extension-text-align'
import TextStyle from '@tiptap/extension-text-style'
import { BackgroundColor, TextColor } from '../marks/colors'
@@ -85,6 +86,7 @@ export const ServerKit = Extension.create<ServerKitOptions>({
}),
CodeBlockExtension.configure(codeBlockOptions),
CodeExtension.configure(codeOptions),
MermaidExtension.configure(mermaidOptions),
...tableExtensions,
...taskListExtensions,
...fileExtensions,
+1
View File
@@ -38,6 +38,7 @@ export enum MarkupNodeType {
table_row = 'tableRow',
table_cell = 'tableCell',
table_header = 'tableHeader',
mermaid = 'mermaid',
comment = 'comment'
}
+44
View File
@@ -0,0 +1,44 @@
//
// Copyright © 2024 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 CodeBlock, { CodeBlockOptions } from '@tiptap/extension-code-block'
import { codeBlockOptions } from './codeblock'
export const mermaidOptions: CodeBlockOptions = {
...codeBlockOptions,
defaultLanguage: 'mermaid'
}
export const MermaidExtension = CodeBlock.extend({
name: 'mermaid',
group: 'block',
parseHTML () {
return [
{
tag: 'div.mermaid-diagram',
preserveWhitespace: 'full'
}
]
},
addAttributes () {
return {
language: {
default: 'mermaid'
}
}
}
})
@@ -101,6 +101,7 @@
{draggable}
on:dragstart
on:dragover
on:dragend
on:drop
>
{#if isFold && !empty}
@@ -14,12 +14,12 @@
-->
<script lang="ts">
import { Attachment } from '@hcengineering/attachment'
import core, { type Doc, type Ref, type WithLookup } from '@hcengineering/core'
import { type Doc, type Ref, type WithLookup } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import attachment from '../plugin'
import { AttachmentImageSize } from '../types'
import AttachmentList from './AttachmentList.svelte'
import AttachmentGroup from './AttachmentGroup.svelte'
export let value: Doc & { attachments?: number }
export let attachments: Attachment[] | undefined = undefined
@@ -39,7 +39,6 @@
resAttachments = attachments
return
}
if (value && value.attachments && value.attachments > 0) {
query.query(
attachment.class.Attachment,
@@ -60,4 +59,4 @@
})
</script>
<AttachmentList attachments={resAttachments} {savedAttachmentsIds} {imageSize} {videoPreload} />
<AttachmentGroup attachments={resAttachments} {savedAttachmentsIds} {imageSize} {videoPreload} />
@@ -42,6 +42,7 @@
function openAttachment (): void {
showAttachmentPreviewPopup(value)
}
$: src = getFileUrl(value.file, value.name)
</script>
@@ -0,0 +1,51 @@
<!-- //
// Copyright © 2025 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.
// -->
<script lang="ts">
import { type Attachment } from '@hcengineering/attachment'
import { type WithLookup, Ref } from '@hcengineering/core'
import { AttachmentImageSize } from '../types'
import AttachmentList from './AttachmentList.svelte'
import LinkPreviewList from './LinkPreviewList.svelte'
export let attachments: WithLookup<Attachment>[]
export let savedAttachmentsIds: Ref<Attachment>[] = []
export let imageSize: AttachmentImageSize = 'auto'
export let videoPreload = true
let otherAttachments: WithLookup<Attachment>[]
let linkPreviewAttachments: WithLookup<Attachment>[]
$: filter(attachments)
function filter (value: WithLookup<Attachment>[]): void {
linkPreviewAttachments = []
otherAttachments = []
for (const attachment of value) {
if (attachment === undefined) {
continue
}
if (attachment.type === 'application/link-preview') {
linkPreviewAttachments.push(attachment)
} else {
otherAttachments.push(attachment)
}
}
}
</script>
<div class="gapV-2">
<AttachmentList attachments={otherAttachments} {savedAttachmentsIds} {imageSize} {videoPreload} />
<LinkPreviewList attachments={linkPreviewAttachments} />
</div>
@@ -16,7 +16,6 @@
import { Attachment } from '@hcengineering/attachment'
import { Ref, type WithLookup } from '@hcengineering/core'
import { Scroller } from '@hcengineering/ui'
import { AttachmentImageSize } from '../types'
import AttachmentPreview from './AttachmentPreview.svelte'
@@ -19,7 +19,7 @@
import { ActionIcon, IconAdd, Label, Loading } from '@hcengineering/ui'
import type { Doc, WithLookup } from '@hcengineering/core'
import core from '@hcengineering/core'
import { setPlatformStatus, unknownError } from '@hcengineering/platform'
import { AttachmentPresenter } from '..'
import attachment from '../plugin'
@@ -21,14 +21,15 @@
getBlobRef,
getFileUrl,
previewTypes,
getJsonOrEmpty,
sizeToWidth
} from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
import { Label, Spinner } from '@hcengineering/ui'
import { permissionsStore } from '@hcengineering/view-resources'
import WebIcon from './icons/Web.svelte'
import filesize from 'filesize'
import { createEventDispatcher } from 'svelte'
import { getType, openAttachmentInSidebar, showAttachmentPreviewPopup } from '../utils'
import AttachmentName from './AttachmentName.svelte'
export let value: WithLookup<Attachment> | undefined
@@ -38,9 +39,10 @@
const dispatch = createEventDispatcher()
const maxLenght: number = 30
const maxLength: number = 30
const trimFilename = (fname: string): string =>
fname.length > maxLenght ? fname.substr(0, (maxLenght - 1) / 2) + '...' + fname.substr(-(maxLenght - 1) / 2) : fname
fname.length > maxLength ? fname.substr(0, (maxLength - 1) / 2) + '...' + fname.substr(-(maxLength - 1) / 2) : fname
$: canRemove =
removable &&
@@ -58,7 +60,10 @@
return getType(contentType) === 'image'
}
let canPreview: boolean = false
let canPreview = false
let useDefaultIcon = false
const canLinkPreview = value?.type.includes('link-preview') ?? false
$: if (value !== undefined) {
void canPreviewFile(value.type, $previewTypes).then((res) => {
canPreview = res
@@ -106,49 +111,36 @@
{:else}
<div class="flex-row-center attachment-container">
{#if value}
{#await getBlobRef(value.file, value.name, sizeToWidth('large')) then valueRef}
<a
class="no-line"
style:flex-shrink={0}
href={valueRef.src}
download={value.name}
on:click={clickHandler}
on:mousedown={middleClickHandler}
on:dragstart={dragStart}
>
{#if showPreview && isImage(value.type)}
<img
src={valueRef.src}
data-id={value.file}
srcset={valueRef.srcset}
class="flex-center icon"
class:svg={value.type === 'image/svg+xml'}
class:image={isImage(value.type)}
alt={value.name}
/>
{:else}
<div class="flex-center icon">
{iconLabel(value.name)}
</div>
{/if}
</a>
<div class="flex-col info-container">
<div class="name">
<a href={valueRef.src} download={value.name} on:click={clickHandler} on:mousedown={middleClickHandler}>
{trimFilename(value.name)}
</a>
{#if canLinkPreview}
{#await getJsonOrEmpty(value.file, value.name)}
<Spinner size="small" />
{:then linkPreviewDetails}
<div class="flex-center icon">
{#if linkPreviewDetails.icon !== undefined && !useDefaultIcon}
<img
src={linkPreviewDetails.icon}
class="link-preview-icon"
alt="link-preview"
on:error={() => {
useDefaultIcon = true
}}
/>
{:else}
<WebIcon size="medium" />
{/if}
</div>
<div class="info-content flex-row-center">
{filesize(value.size, { spacer: '' })}
<span class="actions inline-flex clear-mins ml-1 gap-1">
<span>•</span>
<a class="no-line colorInherit" href={valueRef.src} download={value.name} bind:this={download}>
<Label label={presentation.string.Download} />
</a>
{#if canRemove}
<span>•</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="flex-col info-container">
<div class="name">
<a target="_blank" class="no-line" style:flex-shrink={0} href={linkPreviewDetails.url}
>{trimFilename(linkPreviewDetails?.title ?? value.name)}</a
>
</div>
<div class="info-content flex-row-center">
<span class="actions inline-flex clear-mins gap-1">
{#if linkPreviewDetails.description}
{trimFilename(linkPreviewDetails.description)}
<span>•</span>
{/if}
<span
class="remove-link"
on:click={(ev) => {
@@ -159,16 +151,79 @@
>
<Label label={presentation.string.Delete} />
</span>
{/if}
</span>
</span>
</div>
</div>
</div>
{/await}
{/await}
{:else}
{#await getBlobRef(value.file, value.name, sizeToWidth('large')) then valueRef}
<a
class="no-line"
style:flex-shrink={0}
href={valueRef.src}
download={value.name}
on:click={clickHandler}
on:mousedown={middleClickHandler}
on:dragstart={dragStart}
>
{#if showPreview && isImage(value.type)}
<img
src={valueRef.src}
data-id={value.file}
srcset={valueRef.srcset}
class="flex-center icon"
class:svg={value.type === 'image/svg+xml'}
class:image={isImage(value.type)}
alt={value.name}
/>
{:else}
<div class="flex-center icon">
{iconLabel(value.name)}
</div>
{/if}
</a>
<div class="flex-col info-container">
<div class="name">
<a href={valueRef.src} download={value.name} on:click={clickHandler} on:mousedown={middleClickHandler}>
{trimFilename(value.name)}
</a>
</div>
<div class="info-content flex-row-center">
{filesize(value.size, { spacer: '' })}
<span class="actions inline-flex clear-mins ml-1 gap-1">
<span>•</span>
<a class="no-line colorInherit" href={valueRef.src} download={value.name} bind:this={download}>
<Label label={presentation.string.Download} />
</a>
{#if canRemove}
<span>•</span>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<span
class="remove-link"
on:click={(ev) => {
ev.stopPropagation()
ev.preventDefault()
dispatch('remove', value)
}}
>
<Label label={presentation.string.Delete} />
</span>
{/if}
</span>
</div>
</div>
{/await}
{/if}
{/if}
</div>
{/if}
<style lang="scss">
.link-preview-icon {
max-width: 32px;
max-height: 32px;
}
.attachment-container {
flex-shrink: 0;
width: auto;
@@ -18,7 +18,6 @@
import { ListSelectionProvider } from '@hcengineering/view-resources'
import { createEventDispatcher } from 'svelte'
import { WithLookup } from '@hcengineering/core'
import { AttachmentImageSize } from '../types'
import { getType, showAttachmentPreviewPopup } from '../utils'
import AttachmentActions from './AttachmentActions.svelte'
@@ -107,8 +106,8 @@
}
.content {
max-width: 20rem;
max-height: 20rem;
max-width: 25rem;
max-height: 25rem;
scroll-snap-align: start;
}
</style>
@@ -1,5 +1,5 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
// Copyright © 2022, 2025 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
@@ -23,7 +23,10 @@
draftsStore,
getClient,
getFileMetadata,
uploadFile
uploadFile,
fetchLinkPreviewDetails,
canDisplayLinkPreview,
isLinkPreviewEnabled
} from '@hcengineering/presentation'
import { EmptyMarkup } from '@hcengineering/text'
import textEditor, { type RefAction } from '@hcengineering/text-editor'
@@ -32,6 +35,7 @@
import { createEventDispatcher, onDestroy, tick } from 'svelte'
import attachment from '../plugin'
import AttachmentPresenter from './AttachmentPresenter.svelte'
import { rmSync } from 'fs'
export let objectId: Ref<Doc>
export let space: Ref<Space>
@@ -70,6 +74,8 @@
let originalAttachments: Set<Ref<Attachment>> = new Set<Ref<Attachment>>()
const newAttachments: Set<Ref<Attachment>> = new Set<Ref<Attachment>>()
const removedAttachments: Set<Attachment> = new Set<Attachment>()
const maxLinkPreviewCount = 3
const urlSet = new Set<string>()
let progress = false
@@ -88,7 +94,12 @@
_id: { $in: Array.from(attachments.keys()) }
},
(res) => {
existingAttachments = res.map((p) => p._id)
existingAttachments = res.map((p) => {
if (p.type === 'application/link-preview') {
urlSet.add(getUrlKey(p.name))
}
return p._id
})
}
)
} else {
@@ -96,6 +107,33 @@
existingAttachmentsQuery.unsubscribe()
}
function isValidUrl (s: string): boolean {
let url: URL
try {
url = new URL(s)
} catch {
return false
}
return url.protocol.startsWith('http')
}
function longestSegment (s: string): string {
const segments = s.split('.')
let maxLen = segments[0].length
let result = segments[0]
for (const segment of segments) {
if (segment.length > maxLen) {
result = segment
maxLen = segment.length
}
}
return result
}
function getUrlKey (s: string): string {
const url = new URL(s)
return longestSegment(url.host) + url.pathname
}
$: objectId && updateAttachments(objectId)
async function updateAttachments (objectId: Ref<Doc>): Promise<void> {
@@ -111,6 +149,7 @@
})
originalAttachments.clear()
removedAttachments.clear()
urlSet.clear()
query.unsubscribe()
} else if (!skipAttachmentsPreload) {
query.query(
@@ -128,6 +167,7 @@
newAttachments.clear()
originalAttachments.clear()
removedAttachments.clear()
urlSet.clear()
query.unsubscribe()
}
}
@@ -220,6 +260,9 @@
}
async function deleteAttachment (attachment: Attachment): Promise<void> {
if (attachment.type === 'application/link-preview') {
urlSet.delete(getUrlKey(attachment.name))
}
if (originalAttachments.has(attachment._id)) {
await client.removeCollection(
attachment._class,
@@ -274,6 +317,7 @@
})
await limiter.waitProcessing()
newAttachments.clear()
urlSet.clear()
removedAttachments.clear()
saveDraft()
}
@@ -285,10 +329,49 @@
dispatch('message', { message: event.detail, attachments: attachments.size })
}
function updateLinkPreview (): void {
const hrefs = refContainer.getElementsByTagName('a')
const newUrls: string[] = []
for (let i = 0; i < hrefs.length; i++) {
if (hrefs[i].target !== '_blank' || !isValidUrl(hrefs[i].href)) {
continue
}
const key = getUrlKey(hrefs[i].href)
if (urlSet.has(key)) {
continue
}
urlSet.add(key)
newUrls.push(hrefs[i].href)
}
if (newUrls.length > 0) {
void loadLinks(newUrls)
}
}
function onUpdate (event: CustomEvent): void {
if (isLinkPreviewEnabled() && !loading && urlSet.size < maxLinkPreviewCount) {
updateLinkPreview()
}
dispatch('update', { message: event.detail, attachments: attachments.size })
}
async function loadLinks (urls: string[]): Promise<void> {
progress = true
for (const url of urls) {
try {
const meta = await fetchLinkPreviewDetails(url)
if (canDisplayLinkPreview(meta) && meta.url !== undefined) {
const blob = new Blob([JSON.stringify(meta)])
const file = new File([blob], meta.url, { type: 'application/link-preview' })
void createAttachment(file)
}
} catch (err: any) {
void setPlatformStatus(unknownError(err))
}
}
progress = false
}
async function loadFiles (evt: ClipboardEvent): Promise<void> {
progress = true
const files = (evt.clipboardData?.files ?? []) as File[]
@@ -313,15 +396,14 @@
if (!allowed) {
return false
}
const hasFiles = Array.from(evt.clipboardData?.items ?? []).some((i) => i.kind === 'file')
if (!hasFiles) {
return false
if (hasFiles) {
void loadFiles(evt)
return true
}
void loadFiles(evt)
return true
return false
}
</script>
@@ -0,0 +1,28 @@
<!-- //
// Copyright © 2025 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.
// -->
<script lang="ts">
import LinkPreviewPresenter from './LinkPreviewPresenter.svelte'
import { type WithLookup } from '@hcengineering/core'
import { type Attachment } from '@hcengineering/attachment'
export let attachments: WithLookup<Attachment>[] = []
</script>
<div class="gapV-2">
{#each attachments as attachment}
<LinkPreviewPresenter {attachment} />
{/each}
</div>
@@ -0,0 +1,95 @@
<!-- //
// Copyright © 2025 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.
// -->
<script lang="ts">
import { getJsonOrEmpty, type LinkPreviewDetails, canDisplayLinkPreview } from '@hcengineering/presentation'
import { type Attachment } from '@hcengineering/attachment'
import { type WithLookup } from '@hcengineering/core'
import { Spinner } from '@hcengineering/ui'
import WebIcon from './icons/Web.svelte'
export let attachment: WithLookup<Attachment>
let useDefaultIcon = false
let viewModel: LinkPreviewDetails
let canDisplay = false
void getJsonOrEmpty(attachment.file, attachment.name).then((res) => {
viewModel = res as LinkPreviewDetails
canDisplay = canDisplayLinkPreview(viewModel)
})
</script>
<div class="quote content">
{#if canDisplay}
<div class="gapV-2">
<div class="flex gap-1">
{#if viewModel.icon !== undefined && !useDefaultIcon}
<img
src={viewModel.icon}
class="preview-icon"
alt="link-preview-icon"
on:error={() => {
useDefaultIcon = true
}}
/>
{:else}
<WebIcon size="medium" />
{/if}
<b><a target="_blank" href={viewModel.host}>{viewModel.hostname}</a></b>
</div>
<div>
<div>
{#if viewModel.title?.toLowerCase() !== viewModel.hostname?.toLowerCase()}
<b><a target="_blank" href={viewModel.url}>{viewModel.title}</a></b>
{/if}
</div>
<div>
{#if viewModel.description}
{viewModel.description}
{/if}
{#if viewModel.image}
<a target="_blank" href={viewModel.url}>
<img src={viewModel.image} class="round-image" alt="link-preview" />
</a>
{/if}
</div>
</div>
</div>
{:else}
<div class="centered">
<Spinner size="medium" />
</div>
{/if}
</div>
<style lang="scss">
.round-image {
border: 0.5px solid;
border-radius: 7px;
max-width: 25rem;
max-height: 25rem;
}
.preview-icon {
max-width: 22px;
max-height: 22px;
}
.quote {
border-left: 0.25rem solid;
padding-left: 15px;
}
.content {
max-width: 35rem;
max-height: 35rem;
}
</style>
@@ -0,0 +1,25 @@
<!--
// Copyright © 2025 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.
-->
<script lang="ts">
export let size: 'tiny' | 'x-small' | 'small' | 'medium' | 'large' | 'full'
export let fill: string = 'currentColor'
</script>
<svg class="svg-{size}" {fill} viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
<path
d="M14 0.000244141C11.2311 0.000244141 8.52431 0.82133 6.22202 2.35967C3.91973 3.89801 2.12532 6.08451 1.06569 8.64268C0.00606596 11.2008 -0.271181 14.0158 0.269012 16.7315C0.809205 19.4472 2.14258 21.9418 4.10051 23.8997C6.05845 25.8577 8.55301 27.191 11.2687 27.7312C13.9845 28.2714 16.7994 27.9942 19.3576 26.9346C21.9157 25.8749 24.1022 24.0805 25.6406 21.7782C27.1789 19.4759 28 16.7692 28 14.0002C28 10.2872 26.525 6.72626 23.8995 4.10075C21.274 1.47524 17.713 0.000244141 14 0.000244141ZM26 13.0002H20C19.8833 9.31733 18.9291 5.70939 17.21 2.45024C19.5786 3.09814 21.6914 4.45709 23.2632 6.34367C24.8351 8.23024 25.7903 10.5536 26 13.0002ZM14 26.0002C13.7769 26.0152 13.5531 26.0152 13.33 26.0002C11.2583 22.6964 10.1085 18.8984 10 15.0002H18C17.9005 18.8956 16.7612 22.6934 14.7 26.0002C14.467 26.0166 14.2331 26.0166 14 26.0002ZM10 13.0002C10.0995 9.10492 11.2388 5.30707 13.3 2.00024C13.7453 1.95021 14.1947 1.95021 14.64 2.00024C16.7223 5.30104 17.8825 9.09931 18 13.0002H10ZM10.76 2.45024C9.0513 5.71189 8.10746 9.31969 8.00001 13.0002H2.00001C2.20971 10.5536 3.16495 8.23024 4.7368 6.34367C6.30865 4.45709 8.42144 3.09814 10.79 2.45024H10.76ZM2.05001 15.0002H8.05001C8.15437 18.68 9.09478 22.2878 10.8 25.5502C8.43887 24.8954 6.33478 23.5334 4.77056 21.6474C3.20634 19.7614 2.25695 17.4418 2.05001 15.0002ZM17.21 25.5502C18.9291 22.2911 19.8833 18.6832 20 15.0002H26C25.7903 17.4469 24.8351 19.7702 23.2632 21.6568C21.6914 23.5434 19.5786 24.9023 17.21 25.5502Z"
/>
</svg>
@@ -25,6 +25,7 @@ import AttachmentDocList from './components/AttachmentDocList.svelte'
import AttachmentDroppable from './components/AttachmentDroppable.svelte'
import AttachmentGalleryPresenter from './components/AttachmentGalleryPresenter.svelte'
import AttachmentList from './components/AttachmentList.svelte'
import AttachmentGroup from './components/AttachmentGroup.svelte'
import AttachmentPresenter from './components/AttachmentPresenter.svelte'
import AttachmentPreview from './components/AttachmentPreview.svelte'
import AttachmentRefInput from './components/AttachmentRefInput.svelte'
@@ -53,6 +54,7 @@ export {
AttachmentDroppable,
AttachmentGalleryPresenter,
AttachmentList,
AttachmentGroup,
AttachmentPresenter,
AttachmentPreview,
AttachmentRefInput,
+7 -3
View File
@@ -89,7 +89,9 @@ export async function createAttachment (
}
}
export function getType (type: string): 'image' | 'text' | 'json' | 'video' | 'audio' | 'pdf' | 'other' {
export function getType (
type: string
): 'image' | 'text' | 'json' | 'video' | 'audio' | 'pdf' | 'link-preview' | 'other' {
if (type.startsWith('image/')) {
return 'image'
}
@@ -102,13 +104,15 @@ export function getType (type: string): 'image' | 'text' | 'json' | 'video' | 'a
if (type.includes('application/pdf')) {
return 'pdf'
}
if (type === 'application/json') {
if (type.includes('application/json')) {
return 'json'
}
if (type.startsWith('text/')) {
return 'text'
}
if (type.includes('application/link-preview')) {
return 'link-preview'
}
return 'other'
}
@@ -15,8 +15,6 @@
<script lang="ts">
import { Label } from '@hcengineering/ui'
import { IntlString } from '@hcengineering/platform'
import { Timestamp } from '@hcengineering/core'
export let label: IntlString | undefined
</script>
+72 -33
View File
@@ -87,6 +87,8 @@ class RequestPromise {
chunks?: { index: number, data: FindResult<any> }[]
}
const globalRPCHandler: RPCHandler = new RPCHandler()
class Connection implements ClientConnection {
private websocket: ClientSocket | null = null
binaryMode = false
@@ -115,7 +117,7 @@ class Connection implements ClientConnection {
onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise<void>
rpcHandler = new RPCHandler()
rpcHandler: RPCHandler
lastHash?: string
@@ -145,6 +147,7 @@ class Connection implements ClientConnection {
} else {
this.sessionId = generateId()
}
this.rpcHandler = opt?.useGlobalRPCHandler === true ? globalRPCHandler : new RPCHandler()
this.onConnect = opt?.onConnect
@@ -187,6 +190,8 @@ class Connection implements ClientConnection {
this.pingResponse = Date.now()
}
}
}).catch((err) => {
this.ctx.error('failed to send msg', { err })
})
} else {
clearInterval(this.interval)
@@ -336,7 +341,9 @@ class Connection implements ClientConnection {
helloResp.reconnect === true ? ClientConnectEvent.Reconnected : ClientConnectEvent.Connected,
helloResp.lastTx,
this.sessionId
)
)?.catch((err) => {
this.ctx.error('failed to call onConnect', { err })
})
this.schedulePing(socketId)
return
} else {
@@ -345,7 +352,9 @@ class Connection implements ClientConnection {
return
}
if (resp.result === pingConst) {
void this.sendRequest({ method: pingConst, params: [] })
void this.sendRequest({ method: pingConst, params: [] }).catch((err) => {
this.ctx.error('failed to send ping', { err })
})
return
}
if (resp.id !== undefined) {
@@ -416,14 +425,21 @@ class Connection implements ClientConnection {
promise.reject(new PlatformError(resp.error))
} else {
if (request?.handleResult !== undefined) {
void request.handleResult(resp.result).then(() => {
promise.resolve(resp.result)
})
void request
.handleResult(resp.result)
.then(() => {
promise.resolve(resp.result)
})
.catch((err) => {
this.ctx.error('failed to handleResult', { err })
})
} else {
promise.resolve(resp.result)
}
}
void broadcastEvent(client.event.NetworkRequests, this.requests.size)
void broadcastEvent(client.event.NetworkRequests, this.requests.size).catch((err) => {
this.ctx.error('failed to broadcast', { err })
})
} else {
const txArr = Array.isArray(resp.result) ? (resp.result as Tx[]) : [resp.result as Tx]
@@ -437,10 +453,14 @@ class Connection implements ClientConnection {
this.handler(...txArr)
clearTimeout(this.incomingTimer)
void broadcastEvent(client.event.NetworkRequests, this.requests.size + 1)
void broadcastEvent(client.event.NetworkRequests, this.requests.size + 1).catch((err) => {
this.ctx.error('failed to broadcast', { err })
})
this.incomingTimer = setTimeout(() => {
void broadcastEvent(client.event.NetworkRequests, this.requests.size)
void broadcastEvent(client.event.NetworkRequests, this.requests.size).catch((err) => {
this.ctx.error('failed to broadcast', { err })
})
}, 500)
}
}
@@ -476,7 +496,9 @@ class Connection implements ClientConnection {
this.dialTimer = setTimeout(() => {
this.dialTimer = null
if (!opened && !this.closed) {
void this.opt?.onDialTimeout?.()
void this.opt?.onDialTimeout?.()?.catch((err) => {
this.ctx.error('failed to handle dial timeout', { err })
})
this.scheduleOpen(this.ctx, true)
}
}, dialTimeout)
@@ -494,7 +516,9 @@ class Connection implements ClientConnection {
return
}
if (event.data === pingConst) {
void this.sendRequest({ method: pingConst, params: [] })
void this.sendRequest({ method: pingConst, params: [] }).catch((err) => {
this.ctx.error('failed to send ping', { err })
})
return
}
if (
@@ -503,7 +527,9 @@ class Connection implements ClientConnection {
) {
const text = new TextDecoder().decode(event.data)
if (text === pingConst) {
void this.sendRequest({ method: pingConst, params: [] })
void this.sendRequest({ method: pingConst, params: [] }).catch((err) => {
this.ctx.error('failed to send ping', { err })
})
}
if (text === pongConst) {
this.pingResponse = Date.now()
@@ -511,27 +537,32 @@ class Connection implements ClientConnection {
return
}
if (event.data instanceof Blob) {
void event.data.arrayBuffer().then((data) => {
if (this.compressionMode && this.helloReceived) {
void event.data
.arrayBuffer()
.then((data) => {
if (this.compressionMode && this.helloReceived) {
try {
data = uncompress(data)
} catch (err: any) {
// Ignore
console.error(err)
}
}
try {
data = uncompress(data)
const resp = this.rpcHandler.readResponse<any>(data, this.binaryMode)
this.handleMsg(socketId, resp)
} catch (err: any) {
// Ignore
console.error(err)
if (!this.helloReceived) {
// Just error and ignore for now.
console.error(err)
} else {
throw err
}
}
}
try {
const resp = this.rpcHandler.readResponse<any>(data, this.binaryMode)
this.handleMsg(socketId, resp)
} catch (err: any) {
if (!this.helloReceived) {
// Just error and ignore for now.
console.error(err)
} else {
throw err
}
}
})
})
.catch((err) => {
this.ctx.error('failed to decode array buffer', { err })
})
} else {
let data = event.data
if (this.compressionMode && this.helloReceived) {
@@ -561,7 +592,9 @@ class Connection implements ClientConnection {
return
}
// console.log('client websocket closed', socketId, ev?.reason)
void broadcastEvent(client.event.NetworkRequests, -1)
void broadcastEvent(client.event.NetworkRequests, -1).catch((err) => {
this.ctx.error('failed broadcast', { err })
})
this.scheduleOpen(this.ctx, true)
}
wsocket.onopen = () => {
@@ -591,7 +624,9 @@ class Connection implements ClientConnection {
if (opened) {
console.error('client websocket error:', socketId, this.url, this.workspace, this.email)
}
void broadcastEvent(client.event.NetworkRequests, -1)
void broadcastEvent(client.event.NetworkRequests, -1).catch((err) => {
this.ctx.error('failed to broadcast', { err })
})
}
}
@@ -669,7 +704,11 @@ class Connection implements ClientConnection {
ctx.withSync('send-data', {}, () => {
sendData()
})
void ctx.with('broadcast-event', {}, () => broadcastEvent(client.event.NetworkRequests, this.requests.size))
void ctx
.with('broadcast-event', {}, () => broadcastEvent(client.event.NetworkRequests, this.requests.size))
.catch((err) => {
this.ctx.error('failed to broadcast', { err })
})
if (data.method !== pingConst) {
return await promise.promise
}
+13 -2
View File
@@ -133,7 +133,7 @@ export default async () => {
return await Promise.resolve(clientConnection)
}
const modelFilter: ModelFilter = async (txes) => {
const modelFilter: ModelFilter = (txes) => {
if (filterModel === 'client') {
return returnClientTxes(txes)
}
@@ -177,7 +177,18 @@ function returnClientTxes (txes: Tx[]): Tx[] {
'templates:class:TemplateField' as Ref<Class<Doc>>,
'activity:class:DocUpdateMessageViewlet' as Ref<Class<Doc>>,
'core:class:PluginConfiguration' as Ref<Class<Doc>>,
'core:class:DomainIndexConfiguration' as Ref<Class<Doc>>
'core:class:DomainIndexConfiguration' as Ref<Class<Doc>>,
'view:class:ViewletDescriptor' as Ref<Class<Doc>>,
'presentation:class:ComponentPointExtension' as Ref<Class<Doc>>,
'activity:class:ActivityMessagesFilter' as Ref<Class<Doc>>,
'view:class:ActionCategory' as Ref<Class<Doc>>,
'activity:class:ActivityExtension' as Ref<Class<Doc>>,
'chunter:class:ChatMessageViewlet' as Ref<Class<Doc>>,
'activity:class:ActivityMessageControl' as Ref<Class<Doc>>,
'notification:class:ActivityNotificationViewlet' as Ref<Class<Doc>>,
'setting:class:SettingsCategory' as Ref<Class<Doc>>,
'setting:class:WorkspaceSettingCategory' as Ref<Class<Doc>>,
'notification:class:NotificationProvider' as Ref<Class<Doc>>
])
const result = pluginFilterTx(excludedPlugins, configs, txes).filter((tx) => {
+2
View File
@@ -65,6 +65,8 @@ export interface ClientFactoryOptions {
onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise<void>
ctx?: MeasureContext
onDialTimeout?: () => void | Promise<void>
useGlobalRPCHandler?: boolean
}
/**
@@ -70,6 +70,7 @@
"effector": "~22.8.7",
"svelte": "^4.2.19",
"slugify": "^1.6.6",
"fast-equals": "^5.0.1"
"fast-equals": "^5.0.1",
"@hcengineering/rank": "^0.6.4"
}
}
@@ -27,7 +27,6 @@
DocumentState
} from '@hcengineering/controlled-documents'
import { TreeItem } from '@hcengineering/view-resources'
import { compareDocs } from '../../utils'
export let projectMeta: ProjectMeta[] = []
export let childrenByParent: Record<Ref<DocumentMeta>, Array<ProjectMeta>>
@@ -36,13 +35,39 @@
export let getMoreActions: ((obj: Doc, originalEvent?: MouseEvent) => Promise<Action[]>) | undefined = undefined
export let collapsedPrefix: string = ''
export let onDragStart: ((e: DragEvent, object: Ref<DocumentMeta>) => void) | undefined = undefined
export let onDragOver: ((e: DragEvent, object: Ref<DocumentMeta>) => void) | undefined = undefined
export let onDragEnd: ((e: DragEvent, object: Ref<DocumentMeta>) => void) | undefined = undefined
export let onDrop: ((e: DragEvent, object: Ref<DocumentMeta>) => void) | undefined = undefined
export let draggedItem: Ref<DocumentMeta> | undefined = undefined
export let draggedOver: Ref<DocumentMeta> | undefined = undefined
import DropArea from './DropArea.svelte'
const dispatch = createEventDispatcher()
const currentUser = getCurrentAccount() as PersonAccount
const currentPerson = currentUser.person
const docsQuery = createQuery()
let docs: WithLookup<ProjectDocument>[] = []
function sortDocs (meta: ProjectMeta[]): void {
const metaById = new Map(meta.map((p) => [p._id, p]))
docs = docs.slice().sort((a, b) => {
const metaA = metaById.get(a.attachedTo)
const metaB = metaById.get(b.attachedTo)
if (metaA !== undefined && metaB !== undefined) {
return metaA.rank.localeCompare(metaB.rank)
}
return 0
})
}
$: sortDocs(projectMeta)
const docsQuery = createQuery()
$: docsQuery.query(
documents.class.ProjectDocument,
{
@@ -79,12 +104,7 @@
}
}
docs.sort((a, b) => {
if (a.$lookup?.document !== undefined && b.$lookup?.document !== undefined) {
return compareDocs(a.$lookup.document, b.$lookup.document)
}
return 0
})
sortDocs(projectMeta)
},
{
lookup: {
@@ -110,37 +130,62 @@
{#if doc}
{@const children = childrenByParent[doc.attachedTo] ?? []}
<TreeItem
_id={doc._id}
icon={documents.icon.Document}
iconProps={{
fill: 'currentColor'
}}
title={getDocumentName(doc)}
selected={selected === doc._id || selected === prjdoc._id}
isFold
empty={children.length === 0 || children === undefined}
actions={getMoreActions !== undefined ? () => getDocMoreActions(prjdoc) : undefined}
{level}
{collapsedPrefix}
shouldTooltip
on:click={() => {
dispatch('selected', prjdoc)
}}
>
<svelte:fragment slot="dropbox">
{#if children.length}
<svelte:self
projectMeta={children}
{childrenByParent}
{selected}
{collapsedPrefix}
{getMoreActions}
level={level + 1}
on:selected
/>
{/if}
</svelte:fragment>
</TreeItem>
{@const isDraggedOver = draggedOver === doc.attachedTo}
<div class="flex-col relative">
{#if isDraggedOver}
<DropArea />
{/if}
<TreeItem
_id={doc._id}
icon={documents.icon.Document}
iconProps={{
fill: 'currentColor'
}}
title={getDocumentName(doc)}
selected={selected === doc._id || selected === prjdoc._id}
isFold
empty={children.length === 0 || children === undefined}
actions={getMoreActions !== undefined ? () => getDocMoreActions(prjdoc) : undefined}
{level}
{collapsedPrefix}
shouldTooltip
on:click={() => {
dispatch('selected', prjdoc)
}}
draggable={onDragStart !== undefined}
on:dragstart={(evt) => {
onDragStart?.(evt, doc.attachedTo)
}}
on:dragover={(evt) => {
onDragOver?.(evt, doc.attachedTo)
}}
on:dragend={(evt) => {
onDragEnd?.(evt, doc.attachedTo)
}}
on:drop={(evt) => {
onDrop?.(evt, doc.attachedTo)
}}
>
<svelte:fragment slot="dropbox">
{#if children.length}
<svelte:self
projectMeta={children}
{childrenByParent}
{selected}
{collapsedPrefix}
{getMoreActions}
level={level + 1}
{onDragStart}
{onDragOver}
{onDragEnd}
{onDrop}
{draggedItem}
{draggedOver}
on:selected
/>
{/if}
</svelte:fragment>
</TreeItem>
</div>
{/if}
{/each}
@@ -16,7 +16,15 @@
import { WithLookup, type Doc, type Ref, type Space } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { getResource } from '@hcengineering/platform'
import { type Action, getPlatformColorForTextDef, themeStore, navigate, IconEdit, Label } from '@hcengineering/ui'
import {
type Action,
getPlatformColorForTextDef,
themeStore,
navigate,
IconEdit,
Label,
closeTooltip
} from '@hcengineering/ui'
import { getActions as getContributedActions, TreeNode, TreeItem } from '@hcengineering/view-resources'
import { ActionGroup } from '@hcengineering/view'
import {
@@ -40,11 +48,17 @@
getProjectDocsHierarchy,
isEditableProject,
createDocument,
canCreateChildDocument
canCreateChildDocument,
moveDocument,
moveDocumentBefore,
moveDocumentAfter
} from '../../utils'
import documents from '../../plugin'
import DropArea from './DropArea.svelte'
import DropMarker from './DropMarker.svelte'
export let space: DocumentSpace
export let currentSpace: Ref<Space> | undefined
export let currentFragment: string | undefined
@@ -67,6 +81,7 @@
let project: Ref<Project> = documents.ids.NoProject
$: void selectProject(space)
let docsByMeta = new Map<Ref<DocumentMeta>, WithLookup<ProjectMeta>>()
let rootDocs: Array<WithLookup<ProjectMeta>> = []
let childrenByParent: Record<Ref<DocumentMeta>, Array<WithLookup<ProjectMeta>>> = {}
@@ -78,6 +93,7 @@
project
},
(result) => {
docsByMeta = new Map(result.map((r) => [r.meta, r]))
;({ rootDocs, childrenByParent } = getProjectDocsHierarchy(result))
},
{
@@ -116,6 +132,23 @@
selectedControlledDoc = undefined
}
function getAllDescendants (obj: Ref<DocumentMeta>): Ref<DocumentMeta>[] {
const result: Ref<DocumentMeta>[] = []
const queue: Ref<DocumentMeta>[] = [obj]
while (queue.length > 0) {
const next = queue.pop()
if (next === undefined) break
const children = childrenByParent[next] ?? []
const childrenRefs = children.map((p) => p.meta)
result.push(...childrenRefs)
queue.push(...childrenRefs)
}
return result
}
async function selectProject (space: DocumentSpace): Promise<void> {
project = getCurrentProject(space._id) ?? (await getLatestProjectId(space._id, true)) ?? documents.ids.NoProject
}
@@ -181,75 +214,202 @@
return actions
}
let parent: HTMLElement
let draggedItem: Ref<DocumentMeta> | undefined = undefined
let draggedOver: Ref<DocumentMeta> | undefined = undefined
let draggedOverPos: 'before' | 'after' | undefined = undefined
let draggedOverTop: number = 0
let cannotDropTo: Ref<DocumentMeta>[] = []
function canDrop (object: Ref<DocumentMeta>, target: Ref<DocumentMeta>): boolean {
if (object === target) return false
if (cannotDropTo.includes(target)) return false
return true
}
function onDragStart (event: DragEvent, object: Ref<DocumentMeta>): void {
// no prevent default to leverage default rendering
// event.preventDefault()
if (event.dataTransfer === null || event.target === null) {
return
}
cannotDropTo = [object, ...getAllDescendants(object)]
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.dropEffect = 'move'
draggedItem = object
closeTooltip()
}
function getDropPosition (event: DragEvent): { pos: 'before' | 'after' | undefined, top: number } {
const parentRect = parent.getBoundingClientRect()
const targetRect = (event.target as HTMLElement).getBoundingClientRect()
const dropPosition = event.clientY - targetRect.top
const before = dropPosition >= 0 && dropPosition < targetRect.height / 6
const after = dropPosition <= targetRect.height && dropPosition > (5 * targetRect.height) / 6
const pos = before ? 'before' : after ? 'after' : undefined
const top = pos === 'before' ? targetRect.top - parentRect.top - 1 : targetRect.bottom - parentRect.top - 1
return { pos, top }
}
function onDragOver (event: DragEvent, object: Ref<DocumentMeta>): void {
event.preventDefault()
// this is an ugly solution to control drop effect
// we drag and drop elements that are in the depth of components hierarchy
// so we cannot access them directly
if (!(event.target as HTMLElement).draggable) return
if (event.dataTransfer === null || event.target === null || draggedItem === object) {
return
}
if (draggedItem !== undefined && canDrop(draggedItem, object)) {
event.dataTransfer.dropEffect = 'move'
draggedOver = object
const { pos, top } = getDropPosition(event)
draggedOverPos = pos
draggedOverTop = top
} else {
event.dataTransfer.dropEffect = 'none'
}
}
function onDragEnd (event: DragEvent): void {
event.preventDefault()
draggedItem = undefined
draggedOver = undefined
draggedOverPos = undefined
}
function onDrop (event: DragEvent, object: Ref<DocumentMeta>): void {
event.preventDefault()
if (event.dataTransfer === null) {
return
}
if (draggedItem !== undefined && canDrop(draggedItem, object)) {
const doc = docsByMeta.get(draggedItem)
const target = docsByMeta.get(object)
if (doc !== undefined && target !== undefined && doc._id !== target._id) {
if (object === documents.ids.NoParent) {
void moveDocument(doc, doc.space)
} else if (target !== undefined) {
const { pos } = getDropPosition(event)
if (pos === 'before') {
void moveDocumentBefore(doc, target)
} else if (pos === 'after') {
void moveDocumentAfter(doc, target)
} else if (doc.parent !== object) {
void moveDocument(doc, target.space, target)
}
}
}
}
draggedItem = undefined
draggedOver = undefined
}
</script>
<TreeNode
_id={space?._id}
folderIcon
iconProps={{
fill: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
}}
title={space.name}
highlighted={space._id === currentSpace && currentFragment !== undefined && !deselect}
visible={(space._id === currentSpace && currentFragment !== undefined && !deselect) || forciblyСollapsed}
showMenu={pressed}
{forciblyСollapsed}
actions={() => getSpaceActions(space)}
type={'nested'}
>
<svelte:fragment slot="extra">
{#if spaceType?.projects === true}
<ProjectSelector
value={project}
space={space?._id}
maxWidth={'6rem'}
kind={'ghost'}
size={'x-small'}
showDropdownIcon
bind:pressed
on:change={(evt) => {
project = evt.detail
setCurrentProject(space._id, project)
}}
/>
{/if}
</svelte:fragment>
{#if rootDocs.length > 0}
<DocHierarchyLevel
projectMeta={rootDocs}
{childrenByParent}
{selected}
getMoreActions={getDocumentActions}
on:selected={(e) => {
handleDocumentSelected(e.detail)
}}
/>
{:else}
<div class="pseudo-element flex-row-center content-dark-color text-md nowrap">
<Label label={documents.string.NoDocuments} />
</div>
<div bind:this={parent} class="flex-col relative">
{#if draggedOver === documents.ids.NoParent}
<DropArea />
{/if}
<svelte:fragment slot="visible">
{#if (selected || forciblyСollapsed) && selectedControlledDoc}
{@const doc = selectedControlledDoc}
<TreeItem
_id={doc._id}
icon={documents.icon.Document}
iconProps={{
fill: 'currentColor'
{#if draggedOver && draggedOverPos}
<DropMarker top={draggedOverTop} />
{/if}
<TreeNode
_id={space?._id}
folderIcon
iconProps={{
fill: getPlatformColorForTextDef(space.name, $themeStore.dark).icon
}}
title={space.name}
highlighted={space._id === currentSpace && currentFragment !== undefined && !deselect}
visible={(space._id === currentSpace && currentFragment !== undefined && !deselect) || forciblyСollapsed}
showMenu={pressed}
{forciblyСollapsed}
actions={() => getSpaceActions(space)}
type={'nested'}
draggable
on:drop={(evt) => {
onDrop(evt, documents.ids.NoParent)
}}
on:dragover={(evt) => {
onDragOver(evt, documents.ids.NoParent)
}}
on:dragstart={(evt) => {
evt.preventDefault()
}}
>
<svelte:fragment slot="extra">
{#if spaceType?.projects === true}
<ProjectSelector
value={project}
space={space?._id}
maxWidth={'6rem'}
kind={'ghost'}
size={'x-small'}
showDropdownIcon
bind:pressed
on:change={(evt) => {
project = evt.detail
setCurrentProject(space._id, project)
}}
/>
{/if}
</svelte:fragment>
{#if rootDocs.length > 0}
<DocHierarchyLevel
projectMeta={rootDocs}
{childrenByParent}
{selected}
getMoreActions={getDocumentActions}
on:selected={(e) => {
handleDocumentSelected(e.detail)
}}
title={getDocumentName(doc)}
actions={() => getDocumentActions(doc)}
selected
isFold
empty
forciblyСollapsed
{onDragStart}
{onDragEnd}
{onDragOver}
{onDrop}
{draggedItem}
{draggedOver}
/>
{:else}
<div class="pseudo-element flex-row-center content-dark-color text-md nowrap">
<Label label={documents.string.NoDocuments} />
</div>
{/if}
</svelte:fragment>
</TreeNode>
<svelte:fragment slot="visible">
{#if (selected || forciblyСollapsed) && selectedControlledDoc}
{@const doc = selectedControlledDoc}
<TreeItem
_id={doc._id}
icon={documents.icon.Document}
iconProps={{
fill: 'currentColor'
}}
title={getDocumentName(doc)}
actions={() => getDocumentActions(doc)}
selected
isFold
empty
forciblyСollapsed
/>
{/if}
</svelte:fragment>
</TreeNode>
</div>
<style lang="scss">
.pseudo-element {
@@ -0,0 +1,29 @@
<!--
// Copyright © 2024 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.
-->
<div class="drop-area" />
<style lang="scss">
.drop-area {
pointer-events: none;
position: absolute;
left: 0.75rem;
right: 0.75rem;
top: 0;
bottom: 0;
background-color: var(--global-ui-highlight-BackgroundColor);
border-radius: 0.5rem;
}
</style>
@@ -0,0 +1,33 @@
<!--
// Copyright © 2024 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.
-->
<script lang="ts">
export let top: number
</script>
<div class="drop-marker" style="top: {top}px;" />
<style lang="scss">
.drop-marker {
pointer-events: none;
position: absolute;
z-index: 100;
height: 0.125rem;
background-color: var(--primary-button-focused);
left: 0.75rem;
right: 0.75rem;
top: 10rem;
}
</style>
@@ -11,51 +11,53 @@
//
// See the License for the specific language governing permissions and
// limitations under the License.
import core, {
type Class,
type Doc,
type DocumentQuery,
type Hierarchy,
type Ref,
type Tx,
type TxOperations,
type Space,
type Markup,
type Client,
type WithLookup,
SortingOrder,
getCurrentAccount,
checkPermission
} from '@hcengineering/core'
import { type IntlString, translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { type Person, type Employee, type PersonAccount } from '@hcengineering/contact'
import request, { RequestStatus } from '@hcengineering/request'
import { isEmptyMarkup } from '@hcengineering/text'
import { showPopup, getUserTimezone, type Location } from '@hcengineering/ui'
import { type KeyFilter } from '@hcengineering/view'
import chunter from '@hcengineering/chunter'
import { type Employee, type Person, type PersonAccount } from '@hcengineering/contact'
import documents, {
type ControlledDocument,
type Document,
type DocumentRequest,
type DocumentTemplate,
type DocumentSpace,
type DocumentCategory,
type DocumentMeta,
type DocumentComment,
type DocumentMeta,
type DocumentRequest,
type DocumentSpace,
type DocumentTemplate,
type OrgSpace,
type Project,
type ProjectDocument,
type ProjectMeta,
ControlledDocumentState,
DocumentState,
getDocumentName
getDocumentName,
getFirstRank
} from '@hcengineering/controlled-documents'
import { type Request } from '@hcengineering/request'
import core, {
type Class,
type Client,
type Doc,
type DocumentQuery,
type Hierarchy,
type Markup,
type QuerySelector,
type Ref,
type Space,
type Tx,
type TxOperations,
type WithLookup,
SortingOrder,
checkPermission,
getCurrentAccount
} from '@hcengineering/core'
import { type IntlString, translate } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import request, { type Request, RequestStatus } from '@hcengineering/request'
import { isEmptyMarkup } from '@hcengineering/text'
import { type Location, getUserTimezone, showPopup } from '@hcengineering/ui'
import { type KeyFilter } from '@hcengineering/view'
import documentsResources from './plugin'
import { makeRank } from '@hcengineering/rank'
import { getProjectDocumentLink } from './navigation'
import documentsResources from './plugin'
import { wizardOpened } from './stores/wizards/create-document'
export type TranslatedDocumentStates = Readonly<Record<DocumentState, string>>
@@ -654,3 +656,41 @@ export function formatSignatureDate (date: number): string {
second: 'numeric'
})
}
export async function moveDocument (doc: ProjectMeta, space: Ref<Space>, target?: ProjectMeta): Promise<void> {
const client = getClient()
let parent = documents.ids.NoParent
let path: Array<Ref<DocumentMeta>> = []
if (target !== undefined) {
parent = target.meta
path = [target.meta, ...target.path]
}
const prevRank = await getFirstRank(client, space, doc.project, parent)
const rank = makeRank(prevRank, undefined)
await client.update(doc, { parent, path, rank })
}
export async function moveDocumentBefore (doc: ProjectMeta, before: ProjectMeta): Promise<void> {
const client = getClient()
const { space, parent, path } = before
const query = { rank: { $lt: before.rank } as unknown as QuerySelector<ProjectMeta['rank']> }
const lastRank = await getFirstRank(client, space, doc.project, parent, SortingOrder.Descending, query)
const rank = makeRank(lastRank, before.rank)
await client.update(doc, { parent, path, rank })
}
export async function moveDocumentAfter (doc: ProjectMeta, after: ProjectMeta): Promise<void> {
const client = getClient()
const { space, parent, path } = after
const query = { rank: { $gt: after.rank } as unknown as QuerySelector<ProjectMeta['rank']> }
const nextRank = await getFirstRank(client, space, doc.project, parent, SortingOrder.Ascending, query)
const rank = makeRank(after.rank, nextRank)
await client.update(doc, { parent, path, rank })
}
+2 -1
View File
@@ -49,7 +49,8 @@
"@hcengineering/training": "^0.1.0",
"lexorank": "~1.0.4",
"@hcengineering/activity": "^0.6.0",
"@hcengineering/chunter": "^0.6.20"
"@hcengineering/chunter": "^0.6.20",
"@hcengineering/rank": "^0.6.4"
},
"publishConfig": {
"@hcengineering:registry": "https://npm.pkg.github.com"
+13 -4
View File
@@ -27,9 +27,10 @@ import {
type ProjectDocument,
DocumentState
} from './types'
import { makeRank } from '@hcengineering/rank'
import documents from './plugin'
import { TEMPLATE_PREFIX } from './utils'
import { getFirstRank, TEMPLATE_PREFIX } from './utils'
async function getParentPath (client: TxOperations, parent: Ref<ProjectDocument>): Promise<Array<Ref<DocumentMeta>>> {
const parentDocObj = await client.findOne(documents.class.ProjectDocument, {
@@ -175,12 +176,16 @@ export async function createControlledDocMetadata (
path = await getParentPath(client, parent)
}
const parentMeta = path[0] ?? documents.ids.NoParent
const lastRank = await getFirstRank(client, space, projectId, parentMeta)
const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, {
project: projectId,
meta: documentMetaId,
path,
parent: path[0] ?? documents.ids.NoParent,
documents: 0
parent: parentMeta,
documents: 0,
rank: makeRank(lastRank, undefined)
})
const projectDocumentId = await client.addCollection(
@@ -323,12 +328,16 @@ export async function createDocumentTemplateMetadata (
metaId
)
const parentMeta = path[0] ?? documents.ids.NoParent
const lastRank = await getFirstRank(client, space, projectId, parentMeta)
const projectMetaId = await ops.createDoc(documents.class.ProjectMeta, space, {
project: projectId,
meta: documentMetaId,
path,
parent: path[0] ?? documents.ids.NoParent,
documents: 0
documents: 0,
rank: makeRank(lastRank, undefined)
})
const projectDocumentId = await client.addCollection(
+4 -1
View File
@@ -16,7 +16,8 @@ import {
type TypedSpace,
type Timestamp,
SpaceType,
SpaceTypeDescriptor
SpaceTypeDescriptor,
Rank
} from '@hcengineering/core'
import { type TagReference } from '@hcengineering/tags'
import { Request } from '@hcengineering/request'
@@ -91,6 +92,8 @@ export interface ProjectMeta extends Doc {
// head: Ref<HierarchyDocument>
documents: CollectionSize<ProjectDocument>
rank: Rank
}
/**
+34 -2
View File
@@ -12,7 +12,17 @@
// See the License for the specific language governing permissions and
// limitations under the License.
//
import { ApplyOperations, Data, DocumentUpdate, Ref, TxOperations } from '@hcengineering/core'
import {
ApplyOperations,
Data,
DocumentQuery,
DocumentUpdate,
Rank,
Ref,
SortingOrder,
Space,
TxOperations
} from '@hcengineering/core'
import { LexoDecimal, LexoNumeralSystem36, LexoRank } from 'lexorank'
import LexoRankBucket from 'lexorank/lib/lexoRank/lexoRankBucket'
@@ -22,6 +32,7 @@ import {
ChangeControl,
ControlledDocument,
Document,
DocumentMeta,
DocumentSpace,
DocumentState,
Project,
@@ -143,7 +154,8 @@ export async function copyProjectDocuments (
meta: meta.meta,
path: meta.path,
parent: meta.parent,
documents: meta.documents
documents: meta.documents,
rank: meta.rank
})
// copy project docs attached to meta
@@ -165,6 +177,26 @@ export async function copyProjectDocuments (
}
}
/**
* @public
*/
export async function getFirstRank (
client: TxOperations,
space: Ref<Space>,
project: Ref<Project>,
parent: Ref<DocumentMeta>,
sort: SortingOrder = SortingOrder.Descending,
extra: DocumentQuery<ProjectMeta> = {}
): Promise<Rank | undefined> {
const doc = await client.findOne(
documents.class.ProjectMeta,
{ space, project, parent, ...extra },
{ sort: { rank: sort }, projection: { rank: 1 } }
)
return doc?.rank
}
/**
* @public
*/
+12 -1
View File
@@ -119,6 +119,17 @@
"DeleteSpaceTypeConfirm": "Opravdu chcete tento typ prostoru smazat?",
"WorkspaceName": "Název pracovního prostoru",
"Workspace": "Pracovní prostor",
"OwnerOrMaintainerRequired": "Musíte být vlastníkem nebo správcem pracovního prostoru"
"OwnerOrMaintainerRequired": "Musíte být vlastníkem nebo správcem pracovního prostoru",
"Backup": "Záloha",
"BackupLast": "Poslední záloha",
"BackupTotalSnapshots": "Celkem snímků",
"BackupTotalFiles": "Soubory",
"BackupSize": "Velikost zálohy",
"BackupLinkInfo": "Záložní adresář je dostupný na",
"BackupBearerTokenInfo": "Pro přístup k záloze je vyžadován přístupový token. Klikněte na tlačítko pro zkopírování tokenu do schránky.",
"BackupSnapshots": "Snímky",
"BackupFileDownload": "Stáhnout",
"BackupFiles": "Soubory",
"BackupNoBackup": "Žádná záloha není k dispozici"
}
}
+12 -1
View File
@@ -119,6 +119,17 @@
"DeleteSpaceTypeConfirm": "Sind Sie sicher, dass Sie diesen Bereichstyp löschen möchten?",
"WorkspaceName": "Arbeitsbereich-Name",
"Workspace": "Arbeitsbereich",
"OwnerOrMaintainerRequired": "Sie müssen Eigentümer oder Betreuer des Arbeitsbereichs sein"
"OwnerOrMaintainerRequired": "Sie müssen Eigentümer oder Betreuer des Arbeitsbereichs sein",
"Backup": "Sicherung",
"BackupLast": "Letzte Sicherung",
"BackupTotalSnapshots": "Gesamtanzahl Snapshots",
"BackupTotalFiles": "Dateien",
"BackupSize": "Sicherungsgröße",
"BackupLinkInfo": "Ein Sicherungsverzeichnis ist verfügbar unter",
"BackupBearerTokenInfo": "Für den Zugriff auf die Sicherung wird ein Bearer-Token benötigt. Bitte klicken Sie auf die Schaltfläche 'Kopieren', um den Token in die Zwischenablage zu kopieren.",
"BackupSnapshots": "Snapshots",
"BackupFileDownload": "Herunterladen",
"BackupFiles": "Dateien",
"BackupNoBackup": "Keine Sicherung verfügbar"
}
}
+12 -1
View File
@@ -119,6 +119,17 @@
"DeleteSpaceTypeConfirm": "Are you sure you want to delete this space type?",
"WorkspaceName": "Workspace name",
"Workspace": "Workspace",
"OwnerOrMaintainerRequired": "You need to be a workspace Owner or Maintainer"
"OwnerOrMaintainerRequired": "You need to be a workspace Owner or Maintainer",
"Backup": "Backup",
"BackupLast": "Last backup",
"BackupTotalSnapshots": "Total Snapshots",
"BackupTotalFiles": "Files",
"BackupSize": "Backup size",
"BackupLinkInfo": "A backup directory is available at",
"BackupBearerTokenInfo": "A bearer token is required to access the backup. Please click copy to clipboard button to copy the token." ,
"BackupSnapshots": "Snapshots",
"BackupFileDownload": "Download",
"BackupFiles": "Files",
"BackupNoBackup": "No backup available"
}
}
+12 -1
View File
@@ -110,6 +110,17 @@
"DeleteSpaceTypeConfirm": "¿Estás seguro de que quieres eliminar este tipo de espacio?",
"WorkspaceName": "Nombre del espacio de trabajo",
"Workspace": "Espacio de trabajo",
"OwnerOrMaintainerRequired": "Necesitas ser Propietario o Mantenedor del espacio de trabajo"
"OwnerOrMaintainerRequired": "Necesitas ser Propietario o Mantenedor del espacio de trabajo",
"Backup": "Copia de seguridad",
"BackupLast": "Última copia de seguridad",
"BackupTotalSnapshots": "Total de instantáneas",
"BackupTotalFiles": "Archivos",
"BackupSize": "Tamaño de la copia",
"BackupLinkInfo": "El directorio de copias de seguridad está disponible en",
"BackupBearerTokenInfo": "Se requiere un token de acceso para acceder a la copia de seguridad. Por favor, haga clic en el botón para copiar el token al portapapeles.",
"BackupSnapshots": "Instantáneas",
"BackupFileDownload": "Descargar",
"BackupFiles": "Archivos",
"BackupNoBackup": "No hay copias de seguridad disponibles"
}
}
+12 -1
View File
@@ -119,6 +119,17 @@
"DeleteSpaceTypeConfirm": "Êtes-vous sûr de vouloir supprimer ce type d'espace ?",
"WorkspaceName": "Nom de l'espace de travail",
"Workspace": "Espace de travail",
"OwnerOrMaintainerRequired": "Vous devez être propriétaire ou responsable d'un espace de travail"
"OwnerOrMaintainerRequired": "Vous devez être propriétaire ou responsable d'un espace de travail",
"Backup": "Sauvegarde",
"BackupLast": "Dernière sauvegarde",
"BackupTotalSnapshots": "Total des instantanés",
"BackupTotalFiles": "Fichiers",
"BackupSize": "Taille de la sauvegarde",
"BackupLinkInfo": "Un répertoire de sauvegarde est disponible à",
"BackupBearerTokenInfo": "Un jeton d'authentification est nécessaire pour accéder à la sauvegarde. Veuillez cliquer sur le bouton pour copier le jeton dans le presse-papiers.",
"BackupSnapshots": "Instantanés",
"BackupFileDownload": "Télécharger",
"BackupFiles": "Fichiers",
"BackupNoBackup": "Aucune sauvegarde disponible"
}
}
+12 -1
View File
@@ -119,6 +119,17 @@
"DeleteSpaceTypeConfirm": "Sei sicuro di voler eliminare questo tipo di spazio?",
"WorkspaceName": "Nome spazio di lavoro",
"Workspace": "Spazio di lavoro",
"OwnerOrMaintainerRequired": "Devi essere un proprietario o un manutentore dello spazio di lavoro"
"OwnerOrMaintainerRequired": "Devi essere un proprietario o un manutentore dello spazio di lavoro",
"Backup": "Backup",
"BackupLast": "Ultimo backup",
"BackupTotalSnapshots": "Totale istantanee",
"BackupTotalFiles": "File",
"BackupSize": "Dimensione backup",
"BackupLinkInfo": "Una directory di backup è disponibile in",
"BackupBearerTokenInfo": "È richiesto un token di accesso per accedere al backup. Fare clic sul pulsante per copiare il token negli appunti.",
"BackupSnapshots": "Istantanee",
"BackupFileDownload": "Scarica",
"BackupFiles": "File",
"BackupNoBackup": "Nessun backup disponibile"
}
}
+12 -1
View File
@@ -110,6 +110,17 @@
"DeleteSpaceTypeConfirm": "Tem certeza de que deseja excluir este tipo de espaço?",
"WorkspaceName": "Nome do espaço de trabalho",
"Workspace": "Espaço de trabalho",
"OwnerOrMaintainerRequired": "Precisa de ser proprietário ou mantenedor do espaço de trabalho"
"OwnerOrMaintainerRequired": "Precisa de ser proprietário ou mantenedor do espaço de trabalho",
"Backup": "Backup",
"BackupLast": "Último backup",
"BackupTotalSnapshots": "Total de instantâneos",
"BackupTotalFiles": "Arquivos",
"BackupSize": "Tamanho do backup",
"BackupLinkInfo": "Um diretório de backup está disponível em",
"BackupBearerTokenInfo": "É necessário um token de acesso para acessar o backup. Clique no botão para copiar o token para a área de transferência.",
"BackupSnapshots": "Instantâneos",
"BackupFileDownload": "Baixar",
"BackupFiles": "Arquivos",
"BackupNoBackup": "Nenhum backup disponível"
}
}
+12 -1
View File
@@ -120,6 +120,17 @@
"DeleteSpaceTypeConfirm": "Вы действительно хотите удалить этот тип пространства?",
"WorkspaceName": "Название рабочего пространства",
"Workspace": "Рабочее пространство",
"OwnerOrMaintainerRequired": "Вы должны иметь роль Владельца/Сопровождаемого"
"OwnerOrMaintainerRequired": "Вы должны иметь роль Владельца/Сопровождаемого",
"Backup": "Резервная копия",
"BackupLast": "Последняя резервная копия",
"BackupTotalSnapshots": "Всего снимков",
"BackupTotalFiles": "Файлы",
"BackupSize": "Размер резервной копии",
"BackupLinkInfo": "Директория резервных копий доступна по адресу",
"BackupBearerTokenInfo": "Для доступа к резервной копии требуется токен авторизации. Пожалуйста, нажмите кнопку, чтобы скопировать токен в буфер обмена.",
"BackupSnapshots": "Снимки",
"BackupFileDownload": "Скачать",
"BackupFiles": "Файлы",
"BackupNoBackup": "Резервные копии отсутствуют"
}
}
+12 -1
View File
@@ -119,6 +119,17 @@
"DeleteSpaceTypeConfirm": "您确定要删除此空间类型吗?",
"WorkspaceName": "工作区名称",
"Workspace": "工作区",
"OwnerOrMaintainerRequired": "您需要成为工作空间所有者或维护者"
"OwnerOrMaintainerRequired": "您需要成为工作空间所有者或维护者",
"Backup": "备份",
"BackupLast": "最近备份",
"BackupTotalSnapshots": "快照总数",
"BackupTotalFiles": "文件",
"BackupSize": "备份大小",
"BackupLinkInfo": "备份目录位于",
"BackupBearerTokenInfo": "访问备份需要访问令牌。请点击复制按钮将令牌复制到剪贴板。",
"BackupSnapshots": "快照",
"BackupFileDownload": "下载",
"BackupFiles": "文件",
"BackupNoBackup": "暂无可用备份"
}
}
@@ -0,0 +1,355 @@
<!--
// Copyright © 2025 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.
-->
<script lang="ts">
import { AccountRole, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
import { getMetadata } from '@hcengineering/platform'
import presentation from '@hcengineering/presentation'
import { Breadcrumb, Button, Expandable, Header, Label, Link, Loading } from '@hcengineering/ui'
import Scroller from '@hcengineering/ui/src/components/Scroller.svelte'
import view from '@hcengineering/view'
import { onMount } from 'svelte'
import setting from '../plugin'
import { BackupInfo, BackupSnapshot } from '../types'
let loading = true
const backupUrl = getMetadata(setting.metadata.BackupUrl) ?? ''
const token = getMetadata(presentation.metadata.Token) ?? ''
const owner = hasAccountRole(getCurrentAccount(), AccountRole.Owner)
const workspaceId = getMetadata(presentation.metadata.WorkspaceId) ?? ''
let backupInfo:
| {
files: { name: string, size: number }[]
error?: string
info?: BackupInfo
}
| undefined
$: fileSizes = new Map(backupInfo?.files?.map((it) => [it.name, it.size ?? 0]))
function formatDate (timestamp: number): string {
return new Date(timestamp).toLocaleString()
}
function getSize (size: number): string {
const mbSize = size / (1024 * 1024)
if (size < 1024) {
return `${size}b`
}
if (size < 1024 * 1024) {
return `${Math.round(size / 1024)}kb`
}
return `${Math.round(mbSize * 100) / 100}Mb`
}
function getSnapshotSummary (snapshot: BackupSnapshot): string {
let size = 0
Object.values(snapshot.domains).forEach((domain) => {
domain.storage?.forEach((snapshot) => {
size += fileSizes.get(snapshot) ?? 0
})
})
return getSize(size)
}
function getBackupSize (files: { name: string, size: number }[]): string {
let size = 0
files.forEach((file) => {
size += file.size
})
return getSize(size)
}
async function updateBackupInfo (): Promise<void> {
if (owner && backupUrl !== '') {
try {
const response = await fetch(`${backupUrl}/${workspaceId}/index.json`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`
}
})
backupInfo = await response.json()
} catch (err) {
console.error('Failed to fetch backup info:', err)
}
}
loading = false
}
onMount(() => {
void updateBackupInfo()
})
function formatAgoHours (timestamp: number): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
if (diffHours < 24) {
if (diffHours === 0) {
const diffMinutes = Math.floor(diffMs / (1000 * 60))
return `- ${diffMinutes} minutes ago`
}
return `- ${diffHours} hours ago`
}
return ''
}
function getFileUrl (filename: string): string {
return `${backupUrl}/${workspaceId}/${filename}`
}
async function downloadFile (filename: string): Promise<void> {
try {
const response = await fetch(getFileUrl(filename), {
headers: {
Authorization: `Bearer ${token}`
}
})
if (!response.ok) throw new Error('Download failed')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
console.error('Failed to download:', err)
}
}
let copied = false
$: snapshots = backupInfo?.info?.snapshots ?? []
</script>
<div class="hulyComponent p-2 flex-no-shrink">
<Header adaptive={'disabled'}>
<Breadcrumb icon={setting.icon.Setting} label={setting.string.Backup} size={'large'} isCurrent />
</Header>
<Scroller noStretch>
{#if loading}
<Loading size={'small'} />
{:else if backupInfo == null}
<div class="no-backups">
<Label label={setting.string.BackupNoBackup} />
</div>
{:else}
<div class="flex-row flex-gap-4 p-2">
<span class="flex-row-center">
<Label label={setting.string.BackupLast} /> : {snapshots.length > 0
? formatAgoHours(snapshots[snapshots.length - 1].date)
: ''}
</span>
<span class="flex-row-center">
<Label label={setting.string.BackupTotalSnapshots} />: {backupInfo?.info?.snapshots?.length}
<Label label={setting.string.BackupTotalFiles} />: {backupInfo?.files?.length ?? 0}
</span>
<span class="flex-row-center">
<Label label={setting.string.BackupSize} />: {getBackupSize(backupInfo?.files ?? [])}
</span>
<div class="mt-2">
<div class="flex-row-center mt-2">
<Label label={setting.string.BackupLinkInfo} />:
<a href={getFileUrl('index.html')} target="_blank" rel="noopener noreferrer">
<Link label="index.html">
{getFileUrl('index.html')}
</Link>
</a>
</div>
<div class="mt-2">
<Label label={setting.string.BackupBearerTokenInfo} />
</div>
<Button
label={copied ? view.string.Copied : view.string.CopyToClipboard}
on:click={() => {
void navigator.clipboard.writeText(token).then(() => {
copied = true
setTimeout(() => {
copied = false
}, 2500)
})
}}
/>
</div>
</div>
<Expandable bordered>
<svelte:fragment slot="title">
<div class="flex-no-shrink flex-row-center">
<Label label={setting.string.BackupSnapshots} />:
{snapshots.length}
</div>
</svelte:fragment>
{#if backupInfo?.info?.snapshots}
{#each backupInfo.info.snapshots as snapshot, i}
<div class="flex-no-shrink">
<Expandable expandable bordered>
<svelte:fragment slot="title">
<div class="flex-row-center">
#{i}
{formatDate(snapshot.date)}
{formatAgoHours(snapshot.date)} -
{getSnapshotSummary(snapshot)}
</div>
</svelte:fragment>
<div class="p-2">
{#if snapshot.domains}
{#each Object.entries(snapshot.domains) as [domain, data]}
{#if data.storage?.length}
<div class="domain-files">
<div class="domain-header">{domain}</div>
<div class="files-list">
{#each data.storage as filename}
<div class="file-item">
<div class="file-name">{filename}</div>
<div class="file-info">
<span class="file-size">
{getSize(fileSizes.get(filename) ?? 0)}
</span>
<div class="file-actions">
<Button
label={setting.string.BackupFileDownload}
on:click={() => downloadFile(filename)}
/>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
{/each}
{/if}
</div>
</Expandable>
</div>
{/each}
{:else}
<div class="no-backups">
<Label label={setting.string.BackupNoBackup} />
</div>
{/if}
{#if backupInfo?.error}
<div class="error">{backupInfo.error}</div>
{/if}
</Expandable>
<Expandable bordered>
<svelte:fragment slot="title">
<div class="flex-no-shrink flex-row-center">
<Label label={setting.string.BackupFiles} />:
{backupInfo?.files.length}
{getBackupSize(backupInfo?.files ?? [])}
</div>
</svelte:fragment>
{#each backupInfo?.files ?? [] as file}
<div class="file-item">
<div class="file-name">{file.name}</div>
<div class="file-info">
<span class="file-size">
{getSize(file.size ?? 0)}
</span>
<div class="file-actions">
<Button label={setting.string.BackupFileDownload} on:click={() => downloadFile(file.name)} />
</div>
</div>
</div>
{/each}
</Expandable>
{/if}
</Scroller>
</div>
<style lang="scss">
.error {
color: var(--theme-error-color);
padding: 1rem;
background-color: var(--theme-bg-accent);
border-radius: 0.25rem;
}
.domain-files {
padding: 0.5rem 0;
}
.domain-header {
font-weight: 500;
color: var(--theme-content-accent);
}
.files-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
background-color: var(--theme-bg-accent);
border-radius: 0.25rem;
&:hover {
background-color: var(--theme-bg-hover);
}
}
.file-info {
display: flex;
align-items: center;
gap: 1rem;
}
.file-size {
color: var(--theme-content-accent);
font-size: 0.875rem;
}
.file-actions {
display: flex;
gap: 0.5rem;
}
.file-link,
.download-btn {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
text-decoration: none;
color: var(--theme-content-accent);
background: var(--theme-button-bg);
border: 1px solid var(--theme-divider-color);
cursor: pointer;
&:hover {
background: var(--theme-button-bg-hover);
}
}
</style>
+2
View File
@@ -24,6 +24,7 @@ import EditEnum from './components/EditEnum.svelte'
import EnumSetting from './components/EnumSetting.svelte'
import Integrations from './components/Integrations.svelte'
import General from './components/General.svelte'
import Backup from './components/Backup.svelte'
import Owners from './components/Owners.svelte'
import Password from './components/Password.svelte'
import Privacy from './components/Privacy.svelte'
@@ -110,6 +111,7 @@ export default async (): Promise<Resources> => ({
EditEnum,
EnumSetting,
General,
Backup,
Owners,
CreateMixin,
InviteSetting,
+51
View File
@@ -0,0 +1,51 @@
/**
* @public
*/
export interface Snapshot {
added: Map<string, string>
updated: Map<string, string>
removed: string[]
}
/**
* @public
*/
export interface SnapshotV6 {
added: Record<string, string>
updated: Record<string, string>
removed: string[]
}
/**
* @public
*/
export interface DomainData {
snapshot?: string // 0.6 json snapshot
snapshots?: string[]
storage?: string[]
// Some statistics
added: number
updated: number
removed: number
}
/**
* @public
*/
export interface BackupSnapshot {
// _id => hash of added items.
domains: Record<string, DomainData>
date: number
}
/**
* @public
*/
export interface BackupInfo {
workspace: string
version: string
snapshots: BackupSnapshot[]
snapshotsIndex?: number
lastTxId?: string
}
+20 -4
View File
@@ -14,7 +14,7 @@
//
import type { Account, AccountRole, Blob, Class, Configuration, Doc, Mixin, Ref } from '@hcengineering/core'
import type { Plugin } from '@hcengineering/platform'
import type { Metadata, Plugin } from '@hcengineering/platform'
import { Asset, IntlString, Resource, plugin } from '@hcengineering/platform'
import { TemplateField, TemplateFieldCategory } from '@hcengineering/templates'
import { AnyComponent } from '@hcengineering/ui'
@@ -133,7 +133,8 @@ export default plugin(settingId, {
InviteSettings: '' as Ref<Doc>,
WorkspaceSetting: '' as Ref<Doc>,
ManageSpaces: '' as Ref<Doc>,
Spaces: '' as Ref<Doc>
Spaces: '' as Ref<Doc>,
Backup: '' as Ref<Doc>
},
mixin: {
Editable: '' as Ref<Mixin<Editable>>,
@@ -166,7 +167,8 @@ export default plugin(settingId, {
SpaceTypeRolesSectionEditor: '' as AnyComponent,
RoleEditor: '' as AnyComponent,
RoleAssignmentEditor: '' as AnyComponent,
RelationSetting: '' as AnyComponent
RelationSetting: '' as AnyComponent,
Backup: '' as AnyComponent
},
string: {
Settings: '' as IntlString,
@@ -206,7 +208,18 @@ export default plugin(settingId, {
Collections: '' as IntlString,
SpaceTypes: '' as IntlString,
Roles: '' as IntlString,
OwnerOrMaintainerRequired: '' as IntlString
OwnerOrMaintainerRequired: '' as IntlString,
Backup: '' as IntlString,
BackupLast: '' as IntlString,
BackupTotalSnapshots: '' as IntlString,
BackupTotalFiles: '' as IntlString,
BackupSize: '' as IntlString,
BackupLinkInfo: '' as IntlString,
BackupBearerTokenInfo: '' as IntlString,
BackupSnapshots: '' as IntlString,
BackupFileDownload: '' as IntlString,
BackupFiles: '' as IntlString,
BackupNoBackup: '' as IntlString
},
icon: {
AccountSettings: '' as Asset,
@@ -234,5 +247,8 @@ export default plugin(settingId, {
OwnerLastName: '' as Ref<TemplateField>,
OwnerPosition: '' as Ref<TemplateField>,
Value: '' as Ref<TemplateField>
},
metadata: {
BackupUrl: '' as Metadata<string>
}
})
@@ -15,7 +15,7 @@
-->
<script lang="ts">
import { Attachment } from '@hcengineering/attachment'
import { AttachmentList } from '@hcengineering/attachment-resources'
import { AttachmentGroup } from '@hcengineering/attachment-resources'
import { formatName } from '@hcengineering/contact'
import { WithLookup } from '@hcengineering/core'
import { HTMLViewer } from '@hcengineering/presentation'
@@ -54,7 +54,7 @@
</div>
{/if}
{#if attachments}
<AttachmentList {attachments} />
<AttachmentGroup {attachments} />
{/if}
<div class="flex">
<div class="caption-color mr-4"><HTMLViewer value={message.content} /></div>
@@ -50,7 +50,7 @@
object._class,
{ _id: object._id },
(res) => {
relationsA = res[0].$associations ?? {}
relationsA = res?.[0]?.$associations ?? {}
},
{ associations: associationsA.map((a) => [a._id, 1]) }
)
@@ -60,7 +60,7 @@
object._class,
{ _id: object._id },
(res) => {
relationsB = res[0].$associations ?? {}
relationsB = res?.[0]?.$associations ?? {}
},
{ associations: associationsB.map((a) => [a._id, -1]) }
)
@@ -108,6 +108,7 @@
on:click
on:dragstart
on:dragover
on:dragend
on:drop
on:toggle={(ev) => {
if (ev.detail !== undefined) collapsed = !ev.detail
@@ -176,6 +177,7 @@
on:click
on:dragstart
on:dragover
on:dragend
on:drop
>
<slot />
@@ -39,6 +39,7 @@
export let showNotify: boolean = false
export let forciblyСollapsed: boolean = false
export let collapsedPrefix: string = ''
export let draggable: boolean = false
</script>
<TreeElement
@@ -62,7 +63,12 @@
{showMenu}
{noDivider}
{forciblyСollapsed}
{draggable}
on:click
on:dragstart
on:dragover
on:dragend
on:drop
>
<svelte:fragment slot="extra"><slot name="extra" /></svelte:fragment>
<svelte:fragment slot="dropbox"><slot name="dropbox" /></svelte:fragment>
+2 -1
View File
@@ -46,5 +46,6 @@ startFront(metricsContext, {
ANALYTICS_COLLECTOR_URL: process.env.ANALYTICS_COLLECTOR_URL,
AI_URL: process.env.AI_URL,
TELEGRAM_BOT_URL: process.env.TELEGRAM_BOT_URL,
STATS_URL: process.env.STATS_API ?? process.env.STATS_URL
STATS_URL: process.env.STATS_API ?? process.env.STATS_URL,
BACKUP_URL: process.env.BACKUP_URL
})
+9 -1
View File
@@ -2006,7 +2006,15 @@ export async function restore (
})
stream.on('end', () => {
const bf = Buffer.concat(chunks as any)
const doc = JSON.parse(bf.toString()) as Doc
let doc: Doc
try {
doc = JSON.parse(bf.toString()) as Doc
} catch (err) {
ctx.warn('failed to parse blob metadata', { name, workspace: workspaceId.name, err })
next()
return
}
if (doc._class === core.class.Blob || doc._class === 'core:class:BlobData') {
const data = migradeBlobData(doc as Blob, changeset.get(doc._id) as string)
const d = blobs.get(bname) ?? (data !== '' ? Buffer.from(data, 'base64') : undefined)
+2 -1
View File
@@ -88,6 +88,7 @@
"AuthenticationRevokedGithub": "Re-authorization for {login} is required for the proper functioning of the GitHub App",
"UnlinkInstallationTitle": "Uninstall Github App",
"UnlinkInstallation": "Are you sure you want to uninstall the GitHub App? Synchronization will be disabled.",
"RemoveInstallation": "Uninstall"
"RemoveInstallation": "Uninstall",
"Suspended": "Suspended"
}
}
+2 -1
View File
@@ -88,6 +88,7 @@
"AuthenticationRevokedGithub": "É necessária uma nova autorização para {login} para o funcionamento adequado do aplicativo GitHub",
"UnlinkInstallationTitle": "Desinstalar aplicativo Github",
"UnlinkInstallation": "Tem certeza de que deseja desinstalar o aplicativo GitHub? A sincronização será desabilitada.",
"RemoveInstallation": "Desinstalar"
"RemoveInstallation": "Desinstalar",
"Suspended": "Suspended"
}
}
+2 -1
View File
@@ -88,6 +88,7 @@
"AuthenticationRevokedGithub": "Требуется повторная авторизация для {login} для правильной работы приложения GitHub",
"UnlinkInstallationTitle": "Удалить приложение Github",
"UnlinkInstallation": "Вы уверены, что хотите удалить приложение GitHub? Синхронизация будет отключена.",
"RemoveInstallation": "Удалить"
"RemoveInstallation": "Удалить",
"Suspended": "Suspended"
}
}
+2 -1
View File
@@ -88,6 +88,7 @@
"AuthenticationRevokedGithub": "Se requiere una nueva autorización para {login} para el correcto funcionamiento de la aplicación de GitHub",
"UnlinkInstallationTitle": "Desinstalar aplicación de Github",
"UnlinkInstallation": "¿Estás seguro de que deseas desinstalar la aplicación de GitHub? La sincronización se deshabilitará.",
"RemoveInstallation": "Desinstalar"
"RemoveInstallation": "Desinstalar",
"Suspended": "Suspended"
}
}
@@ -26,6 +26,7 @@
import ConnectProject from './ConnectProject.svelte'
import { githubLanguageColors } from './languageColors'
import { sendGHServiceRequest } from './utils'
import { BackgroundColor } from '@hcengineering/text'
export let integration: WithLookup<GithubIntegration>
export let projects: Project[] = []
@@ -141,145 +142,151 @@
}
</script>
<Expandable expanded={true}>
<svelte:fragment slot="title">
<span class="fs-title flex-row-center flex-between flex-grow flex">
<div class="ml-2 mr-2">
<img class="svg-large" src={integration.name.replace('github.com', 'avatars.githubusercontent.com')} />
</div>
{#if integration.name.length > 0}
{integration.name}
{#if (integration.type ?? '') !== ''}
({integration.type})
{/if}
{:else}
<Label label={github.string.ConnectionPending} />
{/if}
{#if !integration.alive}
<Label label={github.string.Closed} />
{/if}
</span>
</svelte:fragment>
<svelte:fragment slot="tools">
<Button
kind={'dangerous'}
label={github.string.RemoveInstallation}
on:click={() => {
showPopup(MessageBox, {
label: github.string.UnlinkInstallationTitle,
message: github.string.UnlinkInstallation,
params: {},
richMessage: true,
action: async () => {
await sendGHServiceRequest('installation-remove', {
installationId: integration.installationId,
token: getMetadata(presentation.metadata.Token) ?? ''
})
}
})
}}
/>
</svelte:fragment>
<div class="flex flex-row-center flex-between m-0-5">
<SearchEdit bind:value={search} width={'100%'} />
</div>
{#each repos.slice(0, limit) as repository}
{@const prj = giProjects.find((it) => it.repositories.includes(repository._id))}
<div
class="repository-card flex-col m-0-5"
class:selected={prj !== undefined}
class:disabled={prj !== undefined && !repository.enabled}
>
<div class="flex flex-row-center flex-between">
<div class="flex-row-center">
<NavLink href={repository.htmlURL}>{repository.name}</NavLink>
<div class="ml-2 visibility">
{repository.visibility}
</div>
{#if !repository.enabled && prj !== undefined}
<div class="ml-2 visibility">
<Label label={github.string.Disabled} />
</div>
<div
style:background-color={!integration.alive ? 'var(--primary-button-disabled)' : undefined}
style:border-radius="0.5rem"
class="p-1"
>
<Expandable expanded={true}>
<svelte:fragment slot="title">
<span class="fs-title flex-row-center flex-between flex-grow flex">
<div class="ml-2 mr-2">
<img class="svg-large" src={integration.name.replace('github.com', 'avatars.githubusercontent.com')} />
</div>
{#if integration.name.length > 0}
{integration.name}
{#if (integration.type ?? '') !== ''}
({integration.type})
{/if}
</div>
<div class="flex flex-row-center">
{#if prj !== undefined}
<div class="mr-2">
<Label label={github.string.LinkedWith} />
</div>
<ObjectPresenter _class={prj._class} objectId={prj._id} value={prj} />
<div class="ml-2">
<Button
kind={'dangerous'}
label={github.string.UnlinkFromProject}
size={'medium'}
on:click={(evt) => {
void onDisconnect(evt, prj, repository)
}}
/>
</div>
<div class="ml-2">
<Button
icon={IconMoreV}
size={'small'}
on:click={(evt) => {
void showMenu(evt, prj, repository)
}}
/>
</div>
{:else}
<ConnectProject {integration} {repository} {projects} />
{/if}
</div>
</div>
<div class="mt-2">
{repository.description ?? ''}
</div>
<div class="flex flex-row-center mt-4">
{#if repository.language != null}
<div class="flex-row-center mr-4">
<div class="lcolor-pin mr-1" style:background-color={githubLanguageColors[repository.language] ?? ''} />
{repository.language}
</div>
{:else}
<Label label={github.string.ConnectionPending} />
{/if}
<div class="flex-row-center">
<Icon icon={IconColStar} fill={'none'} size={'small'} />
<span class="ml-1">{repository.stargazers ?? 0}</span>
</div>
<div class="flex-row-center ml-4">
<Icon icon={github.icon.Forks} size={'small'} />
<span class="ml-1">{repository.forks ?? 0}</span>
</div>
<div class="flex-row-center ml-4">
<Icon icon={tracker.icon.Issue} size={'small'} />
<span class="ml-1">{repository.openIssues ?? 0}</span>
</div>
{#if repository.updatedAt !== undefined}
<div class="flex-row-center ml-4">
<Label label={github.string.Updated} />
<span class="ml-2">
<TimeSince value={repository.updatedAt} />
</span>
</div>
{#if !integration.alive}
<Label label={github.string.Suspended} />
{/if}
</div>
</span>
</svelte:fragment>
<svelte:fragment slot="tools">
<Button
kind={'dangerous'}
label={github.string.RemoveInstallation}
on:click={() => {
showPopup(MessageBox, {
label: github.string.UnlinkInstallationTitle,
message: github.string.UnlinkInstallation,
params: {},
richMessage: true,
action: async () => {
await sendGHServiceRequest('installation-remove', {
installationId: integration.installationId,
token: getMetadata(presentation.metadata.Token) ?? ''
})
}
})
}}
/>
</svelte:fragment>
<div class="flex flex-row-center flex-between m-0-5">
<SearchEdit bind:value={search} width={'100%'} />
</div>
{/each}
{#if repos.length > limit}
<Button
label={ui.string.ShowMore}
on:click={() => {
limit = limit + 10
}}
/>
{/if}
</Expandable>
{#each repos.slice(0, limit) as repository}
{@const prj = giProjects.find((it) => it.repositories.includes(repository._id))}
<div
class="repository-card flex-col m-0-5"
class:selected={prj !== undefined}
class:disabled={prj !== undefined && !repository.enabled}
>
<div class="flex flex-row-center flex-between">
<div class="flex-row-center">
<NavLink href={repository.htmlURL}>{repository.name}</NavLink>
<div class="ml-2 visibility">
{repository.visibility}
</div>
{#if !repository.enabled && prj !== undefined}
<div class="ml-2 visibility">
<Label label={github.string.Disabled} />
</div>
{/if}
</div>
<div class="flex flex-row-center">
{#if prj !== undefined}
<div class="mr-2">
<Label label={github.string.LinkedWith} />
</div>
<ObjectPresenter _class={prj._class} objectId={prj._id} value={prj} />
<div class="ml-2">
<Button
kind={'dangerous'}
label={github.string.UnlinkFromProject}
size={'medium'}
on:click={(evt) => {
void onDisconnect(evt, prj, repository)
}}
/>
</div>
<div class="ml-2">
<Button
icon={IconMoreV}
size={'small'}
on:click={(evt) => {
void showMenu(evt, prj, repository)
}}
/>
</div>
{:else}
<ConnectProject {integration} {repository} {projects} />
{/if}
</div>
</div>
<div class="mt-2">
{repository.description ?? ''}
</div>
<div class="flex flex-row-center mt-4">
{#if repository.language != null}
<div class="flex-row-center mr-4">
<div class="lcolor-pin mr-1" style:background-color={githubLanguageColors[repository.language] ?? ''} />
{repository.language}
</div>
{/if}
<div class="flex-row-center">
<Icon icon={IconColStar} fill={'none'} size={'small'} />
<span class="ml-1">{repository.stargazers ?? 0}</span>
</div>
<div class="flex-row-center ml-4">
<Icon icon={github.icon.Forks} size={'small'} />
<span class="ml-1">{repository.forks ?? 0}</span>
</div>
<div class="flex-row-center ml-4">
<Icon icon={tracker.icon.Issue} size={'small'} />
<span class="ml-1">{repository.openIssues ?? 0}</span>
</div>
{#if repository.updatedAt !== undefined}
<div class="flex-row-center ml-4">
<Label label={github.string.Updated} />
<span class="ml-2">
<TimeSince value={repository.updatedAt} />
</span>
</div>
{/if}
</div>
</div>
{/each}
{#if repos.length > limit}
<Button
label={ui.string.ShowMore}
on:click={() => {
limit = limit + 10
}}
/>
{/if}
</Expandable>
</div>
<style lang="scss">
.bordered {
+2 -1
View File
@@ -600,6 +600,7 @@ export default plugin(githubId, {
AuthenticatedWithGithub: '' as IntlString,
AuthenticationRevokedGithub: '' as IntlString,
AuthenticatedWithGithubEmployee: '' as IntlString,
AuthenticatedWithGithubRequired: '' as IntlString
AuthenticatedWithGithubRequired: '' as IntlString,
Suspended: '' as IntlString
}
})
+1 -1
View File
@@ -10,4 +10,4 @@ export MINIO_SECRET_KEY=minioadmin
export MINIO_ENDPOINT=localhost
export MONGO_URL=mongodb://localhost:27017
rush bundle --to @hcengineering/pod-github
node $@ bundle/bundle.js
node $@ bundle/bundle.js $@
+2 -1
View File
@@ -44,7 +44,8 @@ export async function createPlatformClient (
const connection = await (
await clientResources()
).function.GetClient(token, endpoint, {
onConnect: reconnect
onConnect: reconnect,
useGlobalRPCHandler: true
})
return { client: connection, endpoint }
+14 -6
View File
@@ -31,15 +31,23 @@ Analytics.setTag('application', 'github-service')
let doOnClose: () => Promise<void> = async () => {}
void start(metricsContext, loadBrandingMap(config.BrandingPath)).then((r) => {
doOnClose = r
})
void start(metricsContext, loadBrandingMap(config.BrandingPath))
.then((r) => {
doOnClose = r
})
.catch((err) => {
metricsContext.error('Error', { error: err })
})
const onClose = (): void => {
metricsContext.info('Closed')
void doOnClose().then((r) => {
process.exit(0)
})
void doOnClose()
.then((r) => {
process.exit(0)
})
.catch((err) => {
metricsContext.error('Error', { error: err })
})
}
process.on('uncaughtException', (e) => {
+35 -19
View File
@@ -52,6 +52,7 @@ export interface InstallationRecord {
repositories?: InstallationCreatedEvent['repositories'] | InstallationUnsuspendEvent['repositories']
type: 'Bot' | 'User' | 'Organization'
octokit: Octokit
suspended: boolean
}
export class PlatformWorker {
@@ -593,7 +594,8 @@ export class PlatformWorker {
login: tinst.account.login,
loginNodeId: tinst.account.node_id,
type: tinst.account?.type ?? 'User',
installationName: `${tinst.account?.html_url ?? ''}`
installationName: `${tinst.account?.html_url ?? ''}`,
suspended: install.data.suspended_at != null
}
this.updateInstallationRecord(installationId, val)
}
@@ -609,6 +611,7 @@ export class PlatformWorker {
current.loginNodeId = val.loginNodeId
current.type = val.type
current.installationName = val.installationName
current.suspended = val.suspended
if (val.repositories !== undefined) {
current.repositories = val.repositories
}
@@ -625,7 +628,8 @@ export class PlatformWorker {
login: tinst.account.login,
loginNodeId: tinst.account.node_id,
type: tinst.account?.type ?? 'User',
installationName: `${tinst.account?.html_url ?? ''}`
installationName: `${tinst.account?.html_url ?? ''}`,
suspended: install.installation.suspended_at != null
}
this.updateInstallationRecord(install.installation.id, val)
ctx.info('Found installation', {
@@ -650,11 +654,17 @@ export class PlatformWorker {
type: install.account?.type ?? 'User',
loginNodeId: install.account.node_id,
installationName: iName,
repositories
repositories,
suspended: !enabled
})
const worker = this.getWorker(install.id)
if (worker !== undefined) {
const integeration = worker.integrations.get(install.id)
if (integeration !== undefined) {
integeration.enabled = enabled
}
await worker.syncUserData(this.ctx, await this.getUsers(worker.workspace.name))
await worker.reloadRepositories(install.id)
@@ -662,13 +672,6 @@ export class PlatformWorker {
worker.triggerSync()
}
// Need to inform workspace
const integeration = this.integrations.find((it) => it.installationId === install.id)
if (integeration !== undefined) {
const worker = this.clients.get(integeration.workspace) as GithubWorker
worker?.triggerUpdate()
}
// Check if no workspace was available
this.triggerCheckWorkspaces()
}
@@ -810,17 +813,28 @@ export class PlatformWorker {
this.storageAdapter,
(workspace, event) => {
if (event === ClientConnectEvent.Refresh || event === ClientConnectEvent.Upgraded) {
void this.clients.get(workspace)?.refreshClient(event === ClientConnectEvent.Upgraded)
void this.clients
.get(workspace)
?.refreshClient(event === ClientConnectEvent.Upgraded)
?.catch((err) => {
workerCtx.error('Failed to refresh', { error: err })
})
}
if (initialized) {
// We need to check if workspace is inactive
void this.checkWorkspaceIsActive(token, workspace).then((res) => {
if (res === undefined) {
this.ctx.warn('Workspace is inactive, removing from clients list.', { workspace })
this.clients.delete(workspace)
void worker?.close()
}
})
void this.checkWorkspaceIsActive(token, workspace)
.then((res) => {
if (res === undefined) {
this.ctx.warn('Workspace is inactive, removing from clients list.', { workspace })
this.clients.delete(workspace)
void worker?.close().catch((err) => {
this.ctx.error('Failed to close workspace', { workspace, error: err })
})
}
})
.catch((err) => {
this.ctx.error('Failed to check workspace is active', { workspace, error: err })
})
}
}
)
@@ -879,7 +893,9 @@ export class PlatformWorker {
try {
this.ctx.info('workspace removed from tracking list', { workspace: deleted })
this.clients.delete(deleted)
void ws.close()
void ws.close().catch((err) => {
this.ctx.error('Error', { error: err })
})
} catch (err: any) {
Analytics.handleError(err)
errors++
@@ -207,9 +207,9 @@ export abstract class IssueSyncManagerBase {
})
if (syncData !== undefined) {
const milestone = (
await this.provider.liveQuery.queryFind<GithubMilestone>(github.mixin.GithubMilestone, {})
).find((it) => it.projectNodeId === projectId)
const milestone = await this.client.findOne<GithubMilestone>(github.mixin.GithubMilestone, {
projectNodeId: projectId
})
const target: IssueSyncTarget | undefined =
milestone !== undefined
@@ -264,11 +264,6 @@ export abstract class IssueSyncManagerBase {
let structure = integration.projectStructure.get(target.target._id)
const repositories = await this.provider.liveQuery.queryFind<GithubIntegrationRepository>(
github.class.GithubIntegrationRepository,
{}
)
for (const f of target.prjData.fieldValues?.nodes ?? []) {
if (!('id' in f)) {
continue
@@ -281,8 +276,13 @@ export abstract class IssueSyncManagerBase {
needProjectRefresh = true
}
}
if (needProjectRefresh) {
const repo = repositories.find((it) => it._id === syncData.repository)
if (needProjectRefresh && syncData.repository != null) {
const repo = await this.provider.liveQuery.findOne<GithubIntegrationRepository>(
github.class.GithubIntegrationRepository,
{
_id: syncData.repository
}
)
if (repo !== undefined) {
await this.provider.handleEvent(github.class.GithubIntegration, integration.installationId, repo, {})
@@ -1153,9 +1153,9 @@ export abstract class IssueSyncManagerBase {
if (existingIssue !== undefined) {
// Select a milestone project
if (existingIssue.milestone != null) {
const milestone = (
await this.provider.liveQuery.queryFind<GithubMilestone>(github.mixin.GithubMilestone, {})
).find((it) => it._id === existingIssue.milestone)
const milestone = await this.provider.liveQuery.findOne<GithubMilestone>(github.mixin.GithubMilestone, {
_id: existingIssue.milestone as Ref<GithubMilestone>
})
if (milestone === undefined) {
return
}
+8 -14
View File
@@ -95,13 +95,15 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan
if (projectV2Event) {
const projectV2Event = event as ProjectsV2ItemEvent
const githubProjects = await this.provider.liveQuery.queryFind(github.mixin.GithubProject, {})
const githubProjects = await this.provider.liveQuery.findAll(github.mixin.GithubProject, {
archived: false
})
let prj = githubProjects.find((it) => it.projectNodeId === projectV2Event.projects_v2_item.project_node_id)
if (prj === undefined) {
// Checking for milestones
const m = (await this.provider.liveQuery.queryFind(github.mixin.GithubMilestone, {})).find(
(it) => it.projectNodeId === projectV2Event.projects_v2_item.project_node_id
)
const m = await this.provider.liveQuery.findOne(github.mixin.GithubMilestone, {
projectNodeId: projectV2Event.projects_v2_item.project_node_id
})
if (m !== undefined) {
prj = githubProjects.find((it) => it._id === m.space)
}
@@ -353,13 +355,9 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan
}
if (info.repository == null) {
// No need to sync if component it not yet set
const repos = (await this.provider.getProjectRepositories(container.project._id))
.map((it) => it.name)
.join(', ')
this.ctx.error('Not syncing repository === null', {
url: info.url,
identifier: (existing as Issue).identifier,
repos
identifier: (existing as Issue).identifier
})
return { needSync: githubSyncVersion }
}
@@ -372,13 +370,9 @@ export class IssueSyncManager extends IssueSyncManagerBase implements DocSyncMan
if (info.external === undefined && existing !== undefined) {
const repository = await this.provider.getRepositoryById(info.repository)
if (repository === undefined) {
const repos = (await this.provider.getProjectRepositories(container.project._id))
.map((it) => it.name)
.join(', ')
this.ctx.error('Not syncing repository === undefined', {
url: info.url,
identifier: (existing as Issue).identifier,
repos
identifier: (existing as Issue).identifier
})
return { needSync: githubSyncVersion }
}
@@ -446,9 +446,9 @@ export class ProjectsSyncManager implements DocSyncManager {
if (syncConfig.SupportMilestones && integration.type === 'Organization') {
// Check project milestones and sync their structure as well.
const milestones = (await this.provider.liveQuery.queryFind(github.mixin.GithubMilestone, {})).filter(
(it) => it.space === prj._id
)
const milestones = await this.provider.liveQuery.findAll(github.mixin.GithubMilestone, {
space: prj._id
})
for (const m of milestones) {
if (this.provider.isClosing()) {
break
@@ -100,13 +100,15 @@ export class PullRequestSyncManager extends IssueSyncManagerBase implements DocS
if (projectV2Event) {
const projectV2Event = _event as ProjectsV2ItemEvent
const githubProjects = await this.provider.liveQuery.queryFind(github.mixin.GithubProject, {})
const githubProjects = await this.provider.liveQuery.findAll(github.mixin.GithubProject, {
archived: false
})
let prj = githubProjects.find((it) => it.projectNodeId === projectV2Event.projects_v2_item.project_node_id)
if (prj === undefined) {
// Checking for milestones
const m = (await this.provider.liveQuery.queryFind(github.mixin.GithubMilestone, {})).find(
(it) => it.projectNodeId === projectV2Event.projects_v2_item.project_node_id
)
const m = await this.provider.liveQuery.findOne(github.mixin.GithubMilestone, {
projectNodeId: projectV2Event.projects_v2_item.project_node_id
})
if (m !== undefined) {
prj = githubProjects.find((it) => it._id === m.space)
}
@@ -48,9 +48,9 @@ export class RepositorySyncMapper implements DocSyncManager {
if (repositories !== undefined) {
// We have a list of repositories, so we could create them if they are missing.
// Need to find all repositories, not only active, so passed repositories are not work.
const allRepositories = (
await this.provider.liveQuery.queryFind(github.class.GithubIntegrationRepository, {})
).filter((it) => it.attachedTo === integration.integration._id)
const allRepositories = await this.provider.liveQuery.findAll(github.class.GithubIntegrationRepository, {
attachedTo: integration.integration._id
})
const allRepos: GithubIntegrationRepository[] = [...allRepositories]
for (const repository of repositories) {
@@ -259,9 +259,9 @@ export class RepositorySyncMapper implements DocSyncManager {
const iterable = this.app.eachRepository.iterator({ installationId: integration.installationId })
// Need to find all repositories, not only active, so passed repositories are not work.
const allRepositories = (
await this.provider.liveQuery.queryFind(github.class.GithubIntegrationRepository, {})
).filter((it) => it.attachedTo === integration.integration._id)
const allRepositories = await this.provider.liveQuery.findAll(github.class.GithubIntegrationRepository, {
attachedTo: integration.integration._id
})
let allRepos: GithubIntegrationRepository[] = [...allRepositories]
-2
View File
@@ -126,8 +126,6 @@ export interface IntegrationManager {
isPlatformUser: (account: Ref<PersonAccount>) => Promise<boolean>
getProjectRepositories: (space: Ref<Space>) => Promise<GithubIntegrationRepository[]>
getRepositoryById: (ref?: Ref<GithubIntegrationRepository> | null) => Promise<GithubIntegrationRepository | undefined>
isClosing: () => boolean
+77 -69
View File
@@ -44,7 +44,6 @@ import github, {
GithubIntegration,
GithubIntegrationRepository,
GithubIssue,
GithubMilestone,
GithubProject,
GithubUserInfo,
githubId
@@ -198,11 +197,9 @@ export class GithubWorker implements IntegrationManager {
}
async getContainer (space: Ref<Space>): Promise<ContainerFocus | undefined> {
const project = (
await this.liveQuery.queryFind<GithubProject>(github.mixin.GithubProject, {
_id: space as Ref<GithubProject>
})
).shift()
const project = await this.liveQuery.findOne<GithubProject>(github.mixin.GithubProject, {
_id: space as Ref<GithubProject>
})
if (project !== undefined) {
for (const v of this.integrations.values()) {
if (v.octokit === undefined) {
@@ -219,21 +216,13 @@ export class GithubWorker implements IntegrationManager {
}
}
async getProjectRepositories (space: Ref<Space>): Promise<GithubIntegrationRepository[]> {
const repositories = await this.liveQuery.queryFind<GithubIntegrationRepository>(
github.class.GithubIntegrationRepository,
{}
)
return repositories.filter((it) => it.githubProject === space)
}
async getRepositoryById (
_id?: Ref<GithubIntegrationRepository> | null
): Promise<GithubIntegrationRepository | undefined> {
if (_id != null) {
return (
await this.liveQuery.queryFind<GithubIntegrationRepository>(github.class.GithubIntegrationRepository, { _id })
).shift()
return await this.liveQuery.findOne<GithubIntegrationRepository>(github.class.GithubIntegrationRepository, {
_id
})
}
}
@@ -289,7 +278,9 @@ export class GithubWorker implements IntegrationManager {
})
}
const account = await this.liveQuery.findOne(contact.class.PersonAccount, { email: `github:${userInfo.login}` })
const account = await this.client
.getModel()
.findOne(contact.class.PersonAccount, { email: `github:${userInfo.login}` })
if (account !== undefined) {
const person = await this.liveQuery.findOne(contact.class.Person, { _id: account.person })
// We need to be sure employee are exists.
@@ -330,7 +321,7 @@ export class GithubWorker implements IntegrationManager {
person,
role: AccountRole.User
})
const acc = await this.liveQuery.findOne(contact.class.PersonAccount, { _id: id })
const acc = await this.client.getModel().findOne(contact.class.PersonAccount, { _id: id })
return acc
}
}
@@ -420,7 +411,7 @@ export class GithubWorker implements IntegrationManager {
let person: Ref<Person> | undefined
// try to find by account.
if (userInfo.email != null && userInfo.email.trim().length > 0) {
const personAccount = await this.liveQuery.findOne(contact.class.PersonAccount, { email: userInfo.email })
const personAccount = await this.client.getModel().findOne(contact.class.PersonAccount, { email: userInfo.email })
person = personAccount?.person
}
@@ -472,7 +463,7 @@ export class GithubWorker implements IntegrationManager {
}
async getGithubLogin (container: IntegrationContainer, person: Ref<Person>): Promise<UserInfo | undefined> {
const accounts = await this.liveQuery.queryFind(contact.class.PersonAccount, {})
const accounts = this.client.getModel().findAllSync(contact.class.PersonAccount, {})
const acc = accounts.find((it) => it.person === person && it.email.startsWith('github:'))
if (acc === undefined) {
return // Nobody, will use system account.
@@ -527,7 +518,11 @@ export class GithubWorker implements IntegrationManager {
const ops = new TxOperations(this.client, accountRef)
await syncUser(ctx, record, userAuth, ops, accountRef)
} catch (err: any) {
await this.platform.revokeUserAuth(record)
try {
await this.platform.revokeUserAuth(record)
} catch (err: any) {
ctx.error(`Failed to revoke user ${record._id}`, err)
}
if (err.response?.data?.message !== 'Bad credentials') {
ctx.error(`Failed to sync user ${record._id}`, err)
Analytics.handleError(err)
@@ -551,7 +546,7 @@ export class GithubWorker implements IntegrationManager {
let record = await this.platform.getAccountByRef(this.workspace.name, account)
// const accountRef = this.accounts.find((it) => it._id === account)
const [accountRef] = await this.liveQuery.queryFind(contact.class.PersonAccount, { _id: account })
const [accountRef] = this.client.getModel().findAllSync(contact.class.PersonAccount, { _id: account })
if (record === undefined) {
if (accountRef !== undefined) {
const accounts = this._client.getModel().getAccountByPersonId(accountRef.person)
@@ -651,7 +646,7 @@ export class GithubWorker implements IntegrationManager {
async getProjectStatuses (type: Ref<ProjectType> | undefined): Promise<Status[]> {
if (type === undefined) return []
const statuses = await this.liveQuery.queryFind(core.class.Status, {})
const statuses = this.client.getModel().findAllSync(core.class.Status, {})
const projectType = await this.getProjectType(type)
@@ -663,7 +658,7 @@ export class GithubWorker implements IntegrationManager {
if (type === undefined) return []
const taskType = await this.getTaskType(type)
const statuses = await this.liveQuery.queryFind(core.class.Status, {})
const statuses = this.client.getModel().findAllSync(core.class.Status, {})
const allowedTypes = new Set(taskType?.statuses ?? [])
return statuses.filter((it) => allowedTypes.has(it._id))
@@ -743,37 +738,27 @@ export class GithubWorker implements IntegrationManager {
}
projects: GithubProject[] = []
milestones: GithubMilestone[] = []
async queryProjects (): Promise<void> {
await new Promise<void>((resolve) => {
this.liveQuery.query(github.mixin.GithubProject, {}, (res) => {
let needRefresh = false
if (!equalExceptKeys(this.projects, res, ['sequence', 'modifiedOn', 'modifiedBy'])) {
needRefresh = true
this.liveQuery.query(
github.mixin.GithubProject,
{
archived: false
},
(res) => {
let needRefresh = false
if (!equalExceptKeys(this.projects, res, ['sequence', 'modifiedOn', 'modifiedBy'])) {
needRefresh = true
}
this.projects = res
resolve()
if (needRefresh || this.projects.length !== res.length) {
// Do not trigger update if only sequence is changed.
this.triggerUpdate()
}
}
this.projects = res
resolve()
if (needRefresh || this.projects.length !== res.length) {
// Do not trigger update if only sequence is changed.
this.triggerUpdate()
}
})
})
await new Promise<void>((resolve) => {
this.liveQuery.query(github.mixin.GithubMilestone, {}, (res) => {
let needRefresh = false
if (!equalExceptKeys(this.milestones, res, ['modifiedOn', 'modifiedBy'])) {
needRefresh = true
}
this.milestones = res
resolve()
if (needRefresh || this.milestones.length !== res.length) {
// Do not trigger update if only sequence is changed.
this.triggerUpdate()
}
})
)
})
}
@@ -796,7 +781,7 @@ export class GithubWorker implements IntegrationManager {
loginNodeId: inst.loginNodeId ?? '',
type: inst.type ?? 'User',
installationName: inst?.installationName ?? '',
enabled: true,
enabled: !inst.suspended,
synchronized: new Set(),
projectStructure: new Map(),
syncLock: new Map()
@@ -866,7 +851,7 @@ export class GithubWorker implements IntegrationManager {
private async queryAccounts (): Promise<void> {
const updateAccounts = async (accounts: PersonAccount[]): Promise<void> => {
const persons = await this.liveQuery.queryFind(contact.class.Person, {
const persons = await this.liveQuery.findAll(contact.class.Person, {
_id: { $in: accounts.map((it) => it.person) }
})
const h = this.client.getHierarchy()
@@ -1112,7 +1097,9 @@ export class GithubWorker implements IntegrationManager {
if (this.updateRequests > 0) {
this.updateRequests = 0 // Just in case
await this.updateIntegrations()
void this.performFullSync()
void this.performFullSync().catch((err) => {
this.ctx.error('Failed to perform full sync', { error: err })
})
}
const { projects, repositories } = await this.collectActiveProjects()
@@ -1150,7 +1137,10 @@ export class GithubWorker implements IntegrationManager {
}
}
private async performSync (projects: GithubProject[], repositories: GithubIntegrationRepository[]): Promise<boolean> {
private async performSync (
projects: GithubProject[],
repositories: Pick<GithubIntegrationRepository, '_id'>[]
): Promise<boolean> {
const _projects = toIdMap(projects)
const _repositories = repositories.map((it) => it._id)
@@ -1207,12 +1197,21 @@ export class GithubWorker implements IntegrationManager {
const projects: GithubProject[] = []
const repositories: GithubIntegrationRepository[] = []
const allProjects = await this.liveQuery.queryFind<GithubProject>(github.mixin.GithubProject, { archived: false })
const allRepositories = await this.liveQuery.queryFind(github.class.GithubIntegrationRepository, { enabled: true })
const allProjects = await this.liveQuery.findAll<GithubProject>(github.mixin.GithubProject, {
archived: false
})
const allRepositories = (await this.liveQuery.findAll(github.class.GithubIntegrationRepository, {})).filter(
(it) => it.enabled
)
for (const it of Array.from(this.integrations.values())) {
if (it.enabled) {
const _projects = allProjects.filter((p) => !syncConfig.MainProject || it.projectStructure.has(p._id))
const _projects = []
for (const p of allProjects) {
if (p.integration === it.integration._id && (!syncConfig.MainProject || it.projectStructure.has(p._id))) {
_projects.push(p)
}
}
const prjIds = new Set(_projects.map((it) => it._id))
@@ -1237,13 +1236,13 @@ export class GithubWorker implements IntegrationManager {
const integration = await this._client.findOne(github.class.GithubIntegration, {
installationId: intgr.installationId
})
if (integration === undefined && this.installations.has(intgr.installationId)) {
const installation = this.installations.get(intgr.installationId) as InstallationRecord
const installation = this.installations.get(intgr.installationId) as InstallationRecord
if (integration === undefined && installation !== undefined) {
await this._client.createDoc(
github.class.GithubIntegration,
core.space.Configuration,
{
alive: true,
alive: !installation.suspended,
installationId: intgr.installationId,
clientId: config.ClientID,
name: installation.installationName,
@@ -1257,7 +1256,7 @@ export class GithubWorker implements IntegrationManager {
this.triggerUpdate()
} else if (integration !== undefined) {
await this._client.diffUpdate(integration, {
alive: true
alive: !installation.suspended
})
}
}
@@ -1455,9 +1454,7 @@ export class GithubWorker implements IntegrationManager {
'external sync',
{ installation: integration.installationName, workspace: this.workspace.name },
async () => {
if (!integration.enabled || integration.octokit === undefined) {
return
}
const enabled = integration.enabled && integration.octokit !== undefined
const upd: DocumentUpdate<GithubIntegration> = {}
if (integration.integration.byUser !== integration.login) {
@@ -1470,14 +1467,19 @@ export class GithubWorker implements IntegrationManager {
upd.clientId = config.ClientID
}
if (integration.integration.name !== integration.installationName || !integration.integration.alive) {
if (integration.integration.name !== integration.installationName) {
upd.name = integration.installationName
upd.alive = true
}
if (integration.integration.alive !== enabled) {
upd.alive = enabled
}
if (Object.keys(upd).length > 0) {
await this._client.diffUpdate(integration.integration, upd, Date.now(), integration.integration.createdBy)
this.triggerUpdate()
}
if (!enabled) {
return
}
const derivedClient = new TxOperations(this.client, core.account.System, true)
const { projects, repositories } = await this.collectActiveProjects()
@@ -1591,7 +1593,13 @@ export class GithubWorker implements IntegrationManager {
branding
)
ctx.info('Init worker', { workspace: workspace.workspaceUrl, workspaceId: workspace.workspaceName })
void worker.init()
void worker.init().catch((err) => {
ctx.error('Failed to init worker', {
workspace: workspace.workspaceUrl,
workspaceId: workspace.workspaceName,
error: err
})
})
return worker
} catch (err: any) {
ctx.error('timeout during to connect', { workspace, error: err })
@@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test'
import { generateId, PlatformSetting, PlatformURI } from '../utils'
import { NavigationMenuPage } from '../model/recruiting/navigation-menu-page'
import { ApplicationsPage } from '../model/recruiting/applications-page'
import { test } from '@playwright/test'
import { ApplicationsDetailsPage } from '../model/recruiting/applications-details-page'
import { VacancyDetailsPage } from '../model/recruiting/vacancy-details-page'
import { VacanciesPage } from '../model/recruiting/vacancies-page'
import { ApplicationsPage } from '../model/recruiting/applications-page'
import { NavigationMenuPage } from '../model/recruiting/navigation-menu-page'
import { TalentsPage } from '../model/recruiting/talents-page'
import { VacanciesPage } from '../model/recruiting/vacancies-page'
import { VacancyDetailsPage } from '../model/recruiting/vacancy-details-page'
import { generateId, PlatformSetting, PlatformURI } from '../utils'
test.use({
storageState: PlatformSetting
@@ -106,7 +106,6 @@ test.describe('Application tests', () => {
await applicationsPage.openApplicationByTalentName(talentName)
const applicationId = await applicationsDetailsPage.getApplicationId()
await applicationsDetailsPage.deleteEntity()
expect(page.url()).toContain(applicationId)
await navigationMenuPage.clickButtonApplications()
await applicationsPage.checkApplicationNotExist(applicationId)
})
+1 -1
View File
@@ -94,7 +94,7 @@ export class Transactor extends DurableObject<Env> {
externalStorage: storage,
adapterSecurity: false,
disableTriggers: false,
fulltextUrl: undefined // TODO: Pass fulltext service URI.
fulltextUrl: env.FULLTEXT_URL // TODO: Pass fulltext service URI.
}
)
return await pipeline(ctx, ws, upgrade, broadcast, branding)
+2
View File
@@ -14,4 +14,6 @@ interface Env {
STATS_URL: string | undefined
ENABLE_COMPRESSION: string | undefined
FULLTEXT_URL: string | undefined
}