mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-28 04:25:03 +02:00
Merge remote-tracking branch 'origin/develop' into staging
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,3 +69,4 @@ export * from './preview'
|
||||
export * from './sound'
|
||||
export * from './stats'
|
||||
export * from './drawing'
|
||||
export * from './link-preview'
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -38,6 +38,7 @@ export enum MarkupNodeType {
|
||||
table_row = 'tableRow',
|
||||
table_cell = 'tableCell',
|
||||
table_header = 'tableHeader',
|
||||
mermaid = 'mermaid',
|
||||
comment = 'comment'
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
+85
-40
@@ -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}
|
||||
|
||||
+224
-64
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,17 @@
|
||||
"DeleteSpaceTypeConfirm": "Вы действительно хотите удалить этот тип пространства?",
|
||||
"WorkspaceName": "Название рабочего пространства",
|
||||
"Workspace": "Рабочее пространство",
|
||||
"OwnerOrMaintainerRequired": "Вы должны иметь роль Владельца/Сопровождаемого"
|
||||
"OwnerOrMaintainerRequired": "Вы должны иметь роль Владельца/Сопровождаемого",
|
||||
"Backup": "Резервная копия",
|
||||
"BackupLast": "Последняя резервная копия",
|
||||
"BackupTotalSnapshots": "Всего снимков",
|
||||
"BackupTotalFiles": "Файлы",
|
||||
"BackupSize": "Размер резервной копии",
|
||||
"BackupLinkInfo": "Директория резервных копий доступна по адресу",
|
||||
"BackupBearerTokenInfo": "Для доступа к резервной копии требуется токен авторизации. Пожалуйста, нажмите кнопку, чтобы скопировать токен в буфер обмена.",
|
||||
"BackupSnapshots": "Снимки",
|
||||
"BackupFileDownload": "Скачать",
|
||||
"BackupFiles": "Файлы",
|
||||
"BackupNoBackup": "Резервные копии отсутствуют"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"AuthenticationRevokedGithub": "Требуется повторная авторизация для {login} для правильной работы приложения GitHub",
|
||||
"UnlinkInstallationTitle": "Удалить приложение Github",
|
||||
"UnlinkInstallation": "Вы уверены, что хотите удалить приложение GitHub? Синхронизация будет отключена.",
|
||||
"RemoveInstallation": "Удалить"
|
||||
"RemoveInstallation": "Удалить",
|
||||
"Suspended": "Suspended"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 $@
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,4 +14,6 @@ interface Env {
|
||||
STATS_URL: string | undefined
|
||||
|
||||
ENABLE_COMPRESSION: string | undefined
|
||||
|
||||
FULLTEXT_URL: string | undefined
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user