diff --git a/common/config/rush/command-line.json b/common/config/rush/command-line.json index f43af3a182..54ec8f6446 100644 --- a/common/config/rush/command-line.json +++ b/common/config/rush/command-line.json @@ -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", diff --git a/desktop-package/src/AppIcon.icns b/desktop-package/src/AppIcon.icns index 4ade8c3e54..18decea1c1 100644 Binary files a/desktop-package/src/AppIcon.icns and b/desktop-package/src/AppIcon.icns differ diff --git a/desktop-package/src/AppIcon.ico b/desktop-package/src/AppIcon.ico index 20b8646e33..e5c1f32f44 100644 Binary files a/desktop-package/src/AppIcon.ico and b/desktop-package/src/AppIcon.ico differ diff --git a/desktop-package/src/AppIcon.png b/desktop-package/src/AppIcon.png index e61b0bf112..9515b7b9fe 100644 Binary files a/desktop-package/src/AppIcon.png and b/desktop-package/src/AppIcon.png differ diff --git a/desktop/public/AppIcon.png b/desktop/public/AppIcon.png index e61b0bf112..9515b7b9fe 100644 Binary files a/desktop/public/AppIcon.png and b/desktop/public/AppIcon.png differ diff --git a/desktop/src/ui/index.ts b/desktop/src/ui/index.ts index c1fa495cb4..8733648dde 100644 --- a/desktop/src/ui/index.ts +++ b/desktop/src/ui/index.ts @@ -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, { diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index 869cd0dd7d..3ffc48f869 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -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 { 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 { 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) diff --git a/desktop/src/ui/types.ts b/desktop/src/ui/types.ts index 195ac20381..30f4daef1f 100644 --- a/desktop/src/ui/types.ts +++ b/desktop/src/ui/types.ts @@ -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 { diff --git a/dev/prod/public/config-dev.json b/dev/prod/public/config-dev.json index 2093d02ddc..507037a39f 100644 --- a/dev/prod/public/config-dev.json +++ b/dev/prod/public/config-dev.json @@ -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" } \ No newline at end of file diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index 8579682c7b..cfee6d5396 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -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() } diff --git a/models/controlled-documents/package.json b/models/controlled-documents/package.json index 548cec53fc..dfbac2c566 100644 --- a/models/controlled-documents/package.json +++ b/models/controlled-documents/package.json @@ -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" } } diff --git a/models/controlled-documents/src/migration.ts b/models/controlled-documents/src/migration.ts index c199e79fb0..9f8cdfbb3e 100644 --- a/models/controlled-documents/src/migration.ts +++ b/models/controlled-documents/src/migration.ts @@ -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 { @@ -364,6 +367,42 @@ async function migrateDocSections (client: MigrationClient): Promise { } } +async function migrateProjectMetaRank (client: MigrationClient): Promise { + const projectMeta = await client.find(DOMAIN_DOCUMENTS, { + _class: documents.class.ProjectMeta, + rank: { $exists: false } + }) + + const docMeta = await client.find(DOMAIN_DOCUMENTS, { + _class: documents.class.ProjectDocument, + _id: { $in: projectMeta.map((p) => p.meta) } + }) + + const docMetaById = new Map, 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, update: MigrateUpdate }[] = [] + + 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 { await tryMigrate(client, documentsId, [ @@ -374,6 +413,10 @@ export const documentsOperation: MigrateOperation = { { state: 'migrateDocSections', func: migrateDocSections + }, + { + state: 'migrateProjectMetaRank', + func: migrateProjectMetaRank } ]) }, diff --git a/models/controlled-documents/src/types.ts b/models/controlled-documents/src/types.ts index 16fa68a429..3d2b187ba1 100644 --- a/models/controlled-documents/src/types.ts +++ b/models/controlled-documents/src/types.ts @@ -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 + + @Index(IndexKind.Indexed) + @Hidden() + rank!: Rank } @Model(documents.class.ProjectDocument, core.class.AttachedDoc, DOMAIN_DOCUMENTS) diff --git a/models/setting/src/index.ts b/models/setting/src/index.ts index 4000781794..5d5bda6e90 100644 --- a/models/setting/src/index.ts +++ b/models/setting/src/index.ts @@ -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, diff --git a/packages/core/src/__tests__/client.test.ts b/packages/core/src/__tests__/client.test.ts index 21173e041b..dde583377a 100644 --- a/packages/core/src/__tests__/client.test.ts +++ b/packages/core/src/__tests__/client.test.ts @@ -28,8 +28,8 @@ import { fillConfiguration, pluginFilterTx } from '../utils' import { connect } from './connection' import { genMinModel } from './minmodel' -function filterPlugin (plugin: Plugin): (txes: Tx[]) => Promise { - return async (txes) => { +function filterPlugin (plugin: Plugin): (txes: Tx[]) => Tx[] { + return (txes) => { const configs = new Map, PluginConfiguration>() fillConfiguration(txes, configs) diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 348e92fb3f..f0c66ecffd 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -215,7 +215,7 @@ export interface TxPersistenceStore { store: (model: LoadModelResponse) => Promise } -export type ModelFilter = (tx: Tx[]) => Promise +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 { 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', {}, () => { diff --git a/packages/presentation/src/components/markup/NodeContent.svelte b/packages/presentation/src/components/markup/NodeContent.svelte index 35442b8143..6b75dee004 100644 --- a/packages/presentation/src/components/markup/NodeContent.svelte +++ b/packages/presentation/src/components/markup/NodeContent.svelte @@ -186,6 +186,8 @@ {/each} {/if} + {:else if node.type === MarkupNodeType.mermaid} + {:else if node.type === MarkupNodeType.comment} {:else} diff --git a/packages/presentation/src/drawing.ts b/packages/presentation/src/drawing.ts index 59e20f1232..f5c2a1dabe 100644 --- a/packages/presentation/src/drawing.ts +++ b/packages/presentation/src/drawing.ts @@ -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 diff --git a/packages/presentation/src/file.ts b/packages/presentation/src/file.ts index 9e18569b38..c5beacb19a 100644 --- a/packages/presentation/src/file.ts +++ b/packages/presentation/src/file.ts @@ -200,7 +200,7 @@ export async function deleteFile (id: string): Promise { } }) - 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 { + try { + const fileUrl = getFileUrl(file, name) + const resp = await fetch(fileUrl) + return await resp.json() + } catch { + return {} + } +} diff --git a/packages/presentation/src/index.ts b/packages/presentation/src/index.ts index 62a913052d..0a6f29a548 100644 --- a/packages/presentation/src/index.ts +++ b/packages/presentation/src/index.ts @@ -69,3 +69,4 @@ export * from './preview' export * from './sound' export * from './stats' export * from './drawing' +export * from './link-preview' diff --git a/packages/presentation/src/link-preview.ts b/packages/presentation/src/link-preview.ts new file mode 100644 index 0000000000..580f18ae91 --- /dev/null +++ b/packages/presentation/src/link-preview.ts @@ -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 { + 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 {} + } +} diff --git a/packages/presentation/src/plugin.ts b/packages/presentation/src/plugin.ts index 84c592159e..7737e4a664 100644 --- a/packages/presentation/src/plugin.ts +++ b/packages/presentation/src/plugin.ts @@ -144,6 +144,7 @@ export default plugin(presentationId, { Workspace: '' as Metadata, WorkspaceId: '' as Metadata, FrontUrl: '' as Asset, + LinkPreviewUrl: '' as Metadata, UploadConfig: '' as Metadata, PreviewConfig: '' as Metadata, ClientHook: '' as Metadata, diff --git a/packages/text/src/kits/server-kit.ts b/packages/text/src/kits/server-kit.ts index 0226c673d5..e5366ba7b6 100644 --- a/packages/text/src/kits/server-kit.ts +++ b/packages/text/src/kits/server-kit.ts @@ -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({ }), CodeBlockExtension.configure(codeBlockOptions), CodeExtension.configure(codeOptions), + MermaidExtension.configure(mermaidOptions), ...tableExtensions, ...taskListExtensions, ...fileExtensions, diff --git a/packages/text/src/markup/model.ts b/packages/text/src/markup/model.ts index ae0b052aa6..255a2f401e 100644 --- a/packages/text/src/markup/model.ts +++ b/packages/text/src/markup/model.ts @@ -38,6 +38,7 @@ export enum MarkupNodeType { table_row = 'tableRow', table_cell = 'tableCell', table_header = 'tableHeader', + mermaid = 'mermaid', comment = 'comment' } diff --git a/packages/text/src/nodes/mermaid.ts b/packages/text/src/nodes/mermaid.ts new file mode 100644 index 0000000000..dd128740d7 --- /dev/null +++ b/packages/text/src/nodes/mermaid.ts @@ -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' + } + } + } +}) diff --git a/packages/ui/src/components/NavGroup.svelte b/packages/ui/src/components/NavGroup.svelte index 171846feca..3abca34a91 100644 --- a/packages/ui/src/components/NavGroup.svelte +++ b/packages/ui/src/components/NavGroup.svelte @@ -101,6 +101,7 @@ {draggable} on:dragstart on:dragover + on:dragend on:drop > {#if isFold && !empty} diff --git a/plugins/attachment-resources/src/components/AttachmentDocList.svelte b/plugins/attachment-resources/src/components/AttachmentDocList.svelte index 335893196f..474372c8bc 100644 --- a/plugins/attachment-resources/src/components/AttachmentDocList.svelte +++ b/plugins/attachment-resources/src/components/AttachmentDocList.svelte @@ -14,12 +14,12 @@ --> - + diff --git a/plugins/attachment-resources/src/components/AttachmentGalleryPresenter.svelte b/plugins/attachment-resources/src/components/AttachmentGalleryPresenter.svelte index c12836f692..666e4d3767 100644 --- a/plugins/attachment-resources/src/components/AttachmentGalleryPresenter.svelte +++ b/plugins/attachment-resources/src/components/AttachmentGalleryPresenter.svelte @@ -42,6 +42,7 @@ function openAttachment (): void { showAttachmentPreviewPopup(value) } + $: src = getFileUrl(value.file, value.name) diff --git a/plugins/attachment-resources/src/components/AttachmentGroup.svelte b/plugins/attachment-resources/src/components/AttachmentGroup.svelte new file mode 100644 index 0000000000..96d4295239 --- /dev/null +++ b/plugins/attachment-resources/src/components/AttachmentGroup.svelte @@ -0,0 +1,51 @@ + + + + +
+ + +
diff --git a/plugins/attachment-resources/src/components/AttachmentList.svelte b/plugins/attachment-resources/src/components/AttachmentList.svelte index c71622a2a4..84ac7baaf2 100644 --- a/plugins/attachment-resources/src/components/AttachmentList.svelte +++ b/plugins/attachment-resources/src/components/AttachmentList.svelte @@ -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' diff --git a/plugins/attachment-resources/src/components/AttachmentPopup.svelte b/plugins/attachment-resources/src/components/AttachmentPopup.svelte index a37f5ebd20..41b11006cf 100644 --- a/plugins/attachment-resources/src/components/AttachmentPopup.svelte +++ b/plugins/attachment-resources/src/components/AttachmentPopup.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' diff --git a/plugins/attachment-resources/src/components/AttachmentPresenter.svelte b/plugins/attachment-resources/src/components/AttachmentPresenter.svelte index b199fddf5c..0b02673066 100644 --- a/plugins/attachment-resources/src/components/AttachmentPresenter.svelte +++ b/plugins/attachment-resources/src/components/AttachmentPresenter.svelte @@ -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 | 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}
{#if value} - {#await getBlobRef(value.file, value.name, sizeToWidth('large')) then valueRef} - - {#if showPreview && isImage(value.type)} - {value.name} - {:else} -
- {iconLabel(value.name)} -
- {/if} -
-
-
- - {trimFilename(value.name)} - + {#if canLinkPreview} + {#await getJsonOrEmpty(value.file, value.name)} + + {:then linkPreviewDetails} +
+ {#if linkPreviewDetails.icon !== undefined && !useDefaultIcon} + link-preview { + useDefaultIcon = true + }} + /> + {:else} + + {/if}
-
- {filesize(value.size, { spacer: '' })} - - • - - - {#if canRemove} - • - - +
+ +
+ + {#if linkPreviewDetails.description} + {trimFilename(linkPreviewDetails.description)} + • + {/if} { @@ -159,16 +151,79 @@ > - {/if} - + +
-
- {/await} + {/await} + {:else} + {#await getBlobRef(value.file, value.name, sizeToWidth('large')) then valueRef} + + {#if showPreview && isImage(value.type)} + {value.name} + {:else} +
+ {iconLabel(value.name)} +
+ {/if} +
+
+ +
+ {filesize(value.size, { spacer: '' })} + + • + + + {#if canRemove} + • + + + { + ev.stopPropagation() + ev.preventDefault() + dispatch('remove', value) + }} + > + + {/if} + +
+
+ {/await} + {/if} {/if}
{/if} diff --git a/plugins/attachment-resources/src/components/AttachmentRefInput.svelte b/plugins/attachment-resources/src/components/AttachmentRefInput.svelte index 74fa2b1234..06d19dacfb 100644 --- a/plugins/attachment-resources/src/components/AttachmentRefInput.svelte +++ b/plugins/attachment-resources/src/components/AttachmentRefInput.svelte @@ -1,5 +1,5 @@ + + + +
+ {#each attachments as attachment} + + {/each} +
diff --git a/plugins/attachment-resources/src/components/LinkPreviewPresenter.svelte b/plugins/attachment-resources/src/components/LinkPreviewPresenter.svelte new file mode 100644 index 0000000000..6a24c3bee9 --- /dev/null +++ b/plugins/attachment-resources/src/components/LinkPreviewPresenter.svelte @@ -0,0 +1,95 @@ + + + +
+ {#if canDisplay} +
+
+ {#if viewModel.icon !== undefined && !useDefaultIcon} + link-preview-icon { + useDefaultIcon = true + }} + /> + {:else} + + {/if} + {viewModel.hostname} +
+
+
+ {#if viewModel.title?.toLowerCase() !== viewModel.hostname?.toLowerCase()} + {viewModel.title} + {/if} +
+
+ {#if viewModel.description} + {viewModel.description} + {/if} + {#if viewModel.image} + + link-preview + + {/if} +
+
+
+ {:else} +
+ +
+ {/if} +
+ + diff --git a/plugins/attachment-resources/src/components/icons/Web.svelte b/plugins/attachment-resources/src/components/icons/Web.svelte new file mode 100644 index 0000000000..56d73a2be4 --- /dev/null +++ b/plugins/attachment-resources/src/components/icons/Web.svelte @@ -0,0 +1,25 @@ + + + + + + + diff --git a/plugins/attachment-resources/src/index.ts b/plugins/attachment-resources/src/index.ts index 972ceaaf18..eecca09990 100644 --- a/plugins/attachment-resources/src/index.ts +++ b/plugins/attachment-resources/src/index.ts @@ -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, diff --git a/plugins/attachment-resources/src/utils.ts b/plugins/attachment-resources/src/utils.ts index d48476e0ac..147373c103 100644 --- a/plugins/attachment-resources/src/utils.ts +++ b/plugins/attachment-resources/src/utils.ts @@ -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' } diff --git a/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte b/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte index 4c82e87122..e6dbacd49f 100644 --- a/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte +++ b/plugins/chunter-resources/src/components/chat-message/ChatMessageHeader.svelte @@ -15,8 +15,6 @@ diff --git a/plugins/client-resources/src/connection.ts b/plugins/client-resources/src/connection.ts index 64e921f851..7f566857b9 100644 --- a/plugins/client-resources/src/connection.ts +++ b/plugins/client-resources/src/connection.ts @@ -87,6 +87,8 @@ class RequestPromise { chunks?: { index: number, data: FindResult }[] } +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 - 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(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(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 } diff --git a/plugins/client-resources/src/index.ts b/plugins/client-resources/src/index.ts index 089b3bbd4d..e907be70d9 100644 --- a/plugins/client-resources/src/index.ts +++ b/plugins/client-resources/src/index.ts @@ -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>, 'activity:class:DocUpdateMessageViewlet' as Ref>, 'core:class:PluginConfiguration' as Ref>, - 'core:class:DomainIndexConfiguration' as Ref> + 'core:class:DomainIndexConfiguration' as Ref>, + 'view:class:ViewletDescriptor' as Ref>, + 'presentation:class:ComponentPointExtension' as Ref>, + 'activity:class:ActivityMessagesFilter' as Ref>, + 'view:class:ActionCategory' as Ref>, + 'activity:class:ActivityExtension' as Ref>, + 'chunter:class:ChatMessageViewlet' as Ref>, + 'activity:class:ActivityMessageControl' as Ref>, + 'notification:class:ActivityNotificationViewlet' as Ref>, + 'setting:class:SettingsCategory' as Ref>, + 'setting:class:WorkspaceSettingCategory' as Ref>, + 'notification:class:NotificationProvider' as Ref> ]) const result = pluginFilterTx(excludedPlugins, configs, txes).filter((tx) => { diff --git a/plugins/client/src/index.ts b/plugins/client/src/index.ts index 04f9245097..0e6c09e47e 100644 --- a/plugins/client/src/index.ts +++ b/plugins/client/src/index.ts @@ -65,6 +65,8 @@ export interface ClientFactoryOptions { onConnect?: (event: ClientConnectEvent, lastTx: string | undefined, data: any) => Promise ctx?: MeasureContext onDialTimeout?: () => void | Promise + + useGlobalRPCHandler?: boolean } /** diff --git a/plugins/controlled-documents-resources/package.json b/plugins/controlled-documents-resources/package.json index 6a2e26ded1..2443c51d7f 100644 --- a/plugins/controlled-documents-resources/package.json +++ b/plugins/controlled-documents-resources/package.json @@ -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" } } diff --git a/plugins/controlled-documents-resources/src/components/hierarchy/DocHierarchyLevel.svelte b/plugins/controlled-documents-resources/src/components/hierarchy/DocHierarchyLevel.svelte index d29173eefb..aa88f64bff 100644 --- a/plugins/controlled-documents-resources/src/components/hierarchy/DocHierarchyLevel.svelte +++ b/plugins/controlled-documents-resources/src/components/hierarchy/DocHierarchyLevel.svelte @@ -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, Array> @@ -36,13 +35,39 @@ export let getMoreActions: ((obj: Doc, originalEvent?: MouseEvent) => Promise) | undefined = undefined export let collapsedPrefix: string = '' + export let onDragStart: ((e: DragEvent, object: Ref) => void) | undefined = undefined + export let onDragOver: ((e: DragEvent, object: Ref) => void) | undefined = undefined + export let onDragEnd: ((e: DragEvent, object: Ref) => void) | undefined = undefined + export let onDrop: ((e: DragEvent, object: Ref) => void) | undefined = undefined + + export let draggedItem: Ref | undefined = undefined + export let draggedOver: Ref | 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[] = [] + 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] ?? []} - getDocMoreActions(prjdoc) : undefined} - {level} - {collapsedPrefix} - shouldTooltip - on:click={() => { - dispatch('selected', prjdoc) - }} - > - - {#if children.length} - - {/if} - - + {@const isDraggedOver = draggedOver === doc.attachedTo} +
+ {#if isDraggedOver} + + {/if} + 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) + }} + > + + {#if children.length} + + {/if} + + +
{/if} {/each} diff --git a/plugins/controlled-documents-resources/src/components/hierarchy/DocumentSpacePresenter.svelte b/plugins/controlled-documents-resources/src/components/hierarchy/DocumentSpacePresenter.svelte index 5ecdcd1746..fe189491da 100644 --- a/plugins/controlled-documents-resources/src/components/hierarchy/DocumentSpacePresenter.svelte +++ b/plugins/controlled-documents-resources/src/components/hierarchy/DocumentSpacePresenter.svelte @@ -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 | undefined export let currentFragment: string | undefined @@ -67,6 +81,7 @@ let project: Ref = documents.ids.NoProject $: void selectProject(space) + let docsByMeta = new Map, WithLookup>() let rootDocs: Array> = [] let childrenByParent: Record, Array>> = {} @@ -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): Ref[] { + const result: Ref[] = [] + const queue: Ref[] = [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 { project = getCurrentProject(space._id) ?? (await getLatestProjectId(space._id, true)) ?? documents.ids.NoProject } @@ -181,75 +214,202 @@ return actions } + + let parent: HTMLElement + let draggedItem: Ref | undefined = undefined + let draggedOver: Ref | undefined = undefined + let draggedOverPos: 'before' | 'after' | undefined = undefined + let draggedOverTop: number = 0 + let cannotDropTo: Ref[] = [] + + function canDrop (object: Ref, target: Ref): boolean { + if (object === target) return false + if (cannotDropTo.includes(target)) return false + + return true + } + + function onDragStart (event: DragEvent, object: Ref): 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): 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): 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 + } - getSpaceActions(space)} - type={'nested'} -> - - {#if spaceType?.projects === true} - { - project = evt.detail - setCurrentProject(space._id, project) - }} - /> - {/if} - - - {#if rootDocs.length > 0} - { - handleDocumentSelected(e.detail) - }} - /> - {:else} -
-
+
+ {#if draggedOver === documents.ids.NoParent} + {/if} - - {#if (selected || forciblyСollapsed) && selectedControlledDoc} - {@const doc = selectedControlledDoc} - + {/if} + + 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() + }} + > + + {#if spaceType?.projects === true} + { + project = evt.detail + setCurrentProject(space._id, project) + }} + /> + {/if} + + + {#if rootDocs.length > 0} + { + handleDocumentSelected(e.detail) }} - title={getDocumentName(doc)} - actions={() => getDocumentActions(doc)} - selected - isFold - empty - forciblyСollapsed + {onDragStart} + {onDragEnd} + {onDragOver} + {onDrop} + {draggedItem} + {draggedOver} /> + {:else} +
+
{/if} -
- + + + {#if (selected || forciblyСollapsed) && selectedControlledDoc} + {@const doc = selectedControlledDoc} + getDocumentActions(doc)} + selected + isFold + empty + forciblyСollapsed + /> + {/if} + + +
diff --git a/plugins/controlled-documents-resources/src/components/hierarchy/DropMarker.svelte b/plugins/controlled-documents-resources/src/components/hierarchy/DropMarker.svelte new file mode 100644 index 0000000000..ff849a72e5 --- /dev/null +++ b/plugins/controlled-documents-resources/src/components/hierarchy/DropMarker.svelte @@ -0,0 +1,33 @@ + + + +
+ + diff --git a/plugins/controlled-documents-resources/src/utils.ts b/plugins/controlled-documents-resources/src/utils.ts index 2cff4e07ed..f87f4cc09c 100644 --- a/plugins/controlled-documents-resources/src/utils.ts +++ b/plugins/controlled-documents-resources/src/utils.ts @@ -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> @@ -654,3 +656,41 @@ export function formatSignatureDate (date: number): string { second: 'numeric' }) } + +export async function moveDocument (doc: ProjectMeta, space: Ref, target?: ProjectMeta): Promise { + const client = getClient() + + let parent = documents.ids.NoParent + let path: Array> = [] + 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 { + const client = getClient() + + const { space, parent, path } = before + const query = { rank: { $lt: before.rank } as unknown as QuerySelector } + 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 { + const client = getClient() + + const { space, parent, path } = after + const query = { rank: { $gt: after.rank } as unknown as QuerySelector } + 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 }) +} diff --git a/plugins/controlled-documents/package.json b/plugins/controlled-documents/package.json index 758aa43e17..cd0c8a67f6 100644 --- a/plugins/controlled-documents/package.json +++ b/plugins/controlled-documents/package.json @@ -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" diff --git a/plugins/controlled-documents/src/docutils.ts b/plugins/controlled-documents/src/docutils.ts index 0de1ae6c3f..d66948dd36 100644 --- a/plugins/controlled-documents/src/docutils.ts +++ b/plugins/controlled-documents/src/docutils.ts @@ -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): Promise>> { 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( diff --git a/plugins/controlled-documents/src/types.ts b/plugins/controlled-documents/src/types.ts index 4ec4a756ac..d49a22396d 100644 --- a/plugins/controlled-documents/src/types.ts +++ b/plugins/controlled-documents/src/types.ts @@ -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 documents: CollectionSize + + rank: Rank } /** diff --git a/plugins/controlled-documents/src/utils.ts b/plugins/controlled-documents/src/utils.ts index b5bb9be263..7ae9ad4d13 100644 --- a/plugins/controlled-documents/src/utils.ts +++ b/plugins/controlled-documents/src/utils.ts @@ -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, + project: Ref, + parent: Ref, + sort: SortingOrder = SortingOrder.Descending, + extra: DocumentQuery = {} +): Promise { + const doc = await client.findOne( + documents.class.ProjectMeta, + { space, project, parent, ...extra }, + { sort: { rank: sort }, projection: { rank: 1 } } + ) + + return doc?.rank +} + /** * @public */ diff --git a/plugins/setting-assets/lang/cs.json b/plugins/setting-assets/lang/cs.json index eb17192ec7..1fb667b624 100644 --- a/plugins/setting-assets/lang/cs.json +++ b/plugins/setting-assets/lang/cs.json @@ -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" } } \ No newline at end of file diff --git a/plugins/setting-assets/lang/de.json b/plugins/setting-assets/lang/de.json index f37cc76e6e..517ad825eb 100644 --- a/plugins/setting-assets/lang/de.json +++ b/plugins/setting-assets/lang/de.json @@ -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" } } diff --git a/plugins/setting-assets/lang/en.json b/plugins/setting-assets/lang/en.json index 5ac62762dd..1954a468b0 100644 --- a/plugins/setting-assets/lang/en.json +++ b/plugins/setting-assets/lang/en.json @@ -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" } } diff --git a/plugins/setting-assets/lang/es.json b/plugins/setting-assets/lang/es.json index f184848511..fec61037bd 100644 --- a/plugins/setting-assets/lang/es.json +++ b/plugins/setting-assets/lang/es.json @@ -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" } } \ No newline at end of file diff --git a/plugins/setting-assets/lang/fr.json b/plugins/setting-assets/lang/fr.json index 6df3ac18ef..1c831fd9ce 100644 --- a/plugins/setting-assets/lang/fr.json +++ b/plugins/setting-assets/lang/fr.json @@ -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" } } \ No newline at end of file diff --git a/plugins/setting-assets/lang/it.json b/plugins/setting-assets/lang/it.json index 7c8cba6305..e87c4a798f 100644 --- a/plugins/setting-assets/lang/it.json +++ b/plugins/setting-assets/lang/it.json @@ -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" } } diff --git a/plugins/setting-assets/lang/pt.json b/plugins/setting-assets/lang/pt.json index 81327c2b44..491fa688e8 100644 --- a/plugins/setting-assets/lang/pt.json +++ b/plugins/setting-assets/lang/pt.json @@ -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" } } \ No newline at end of file diff --git a/plugins/setting-assets/lang/ru.json b/plugins/setting-assets/lang/ru.json index c613a8db57..23766dd592 100644 --- a/plugins/setting-assets/lang/ru.json +++ b/plugins/setting-assets/lang/ru.json @@ -120,6 +120,17 @@ "DeleteSpaceTypeConfirm": "Вы действительно хотите удалить этот тип пространства?", "WorkspaceName": "Название рабочего пространства", "Workspace": "Рабочее пространство", - "OwnerOrMaintainerRequired": "Вы должны иметь роль Владельца/Сопровождаемого" + "OwnerOrMaintainerRequired": "Вы должны иметь роль Владельца/Сопровождаемого", + "Backup": "Резервная копия", + "BackupLast": "Последняя резервная копия", + "BackupTotalSnapshots": "Всего снимков", + "BackupTotalFiles": "Файлы", + "BackupSize": "Размер резервной копии", + "BackupLinkInfo": "Директория резервных копий доступна по адресу", + "BackupBearerTokenInfo": "Для доступа к резервной копии требуется токен авторизации. Пожалуйста, нажмите кнопку, чтобы скопировать токен в буфер обмена.", + "BackupSnapshots": "Снимки", + "BackupFileDownload": "Скачать", + "BackupFiles": "Файлы", + "BackupNoBackup": "Резервные копии отсутствуют" } } diff --git a/plugins/setting-assets/lang/zh.json b/plugins/setting-assets/lang/zh.json index ac2092ebe1..14664d5c4a 100644 --- a/plugins/setting-assets/lang/zh.json +++ b/plugins/setting-assets/lang/zh.json @@ -119,6 +119,17 @@ "DeleteSpaceTypeConfirm": "您确定要删除此空间类型吗?", "WorkspaceName": "工作区名称", "Workspace": "工作区", - "OwnerOrMaintainerRequired": "您需要成为工作空间所有者或维护者" + "OwnerOrMaintainerRequired": "您需要成为工作空间所有者或维护者", + "Backup": "备份", + "BackupLast": "最近备份", + "BackupTotalSnapshots": "快照总数", + "BackupTotalFiles": "文件", + "BackupSize": "备份大小", + "BackupLinkInfo": "备份目录位于", + "BackupBearerTokenInfo": "访问备份需要访问令牌。请点击复制按钮将令牌复制到剪贴板。", + "BackupSnapshots": "快照", + "BackupFileDownload": "下载", + "BackupFiles": "文件", + "BackupNoBackup": "暂无可用备份" } } diff --git a/plugins/setting-resources/src/components/Backup.svelte b/plugins/setting-resources/src/components/Backup.svelte new file mode 100644 index 0000000000..24641b779e --- /dev/null +++ b/plugins/setting-resources/src/components/Backup.svelte @@ -0,0 +1,355 @@ + + + +
+
+ +
+ + + {#if loading} + + {:else if backupInfo == null} +
+
+ {:else} +
+ + + + + + + +
+ +
+
+
+
+ + + +
+
+
+ {#if backupInfo?.info?.snapshots} + {#each backupInfo.info.snapshots as snapshot, i} +
+ + +
+ #{i} + {formatDate(snapshot.date)} + {formatAgoHours(snapshot.date)} - + {getSnapshotSummary(snapshot)} +
+
+
+ {#if snapshot.domains} + {#each Object.entries(snapshot.domains) as [domain, data]} + {#if data.storage?.length} +
+
{domain}
+
+ {#each data.storage as filename} +
+
{filename}
+
+ + {getSize(fileSizes.get(filename) ?? 0)} + +
+
+
+
+ {/each} +
+
+ {/if} + {/each} + {/if} +
+
+
+ {/each} + {:else} +
+
+ {/if} + {#if backupInfo?.error} +
{backupInfo.error}
+ {/if} +
+ + + +
+
+
+ {#each backupInfo?.files ?? [] as file} +
+
{file.name}
+
+ + {getSize(file.size ?? 0)} + +
+
+
+
+ {/each} +
+ {/if} +
+
+ + diff --git a/plugins/setting-resources/src/index.ts b/plugins/setting-resources/src/index.ts index 024d4af070..f5ab827bf8 100644 --- a/plugins/setting-resources/src/index.ts +++ b/plugins/setting-resources/src/index.ts @@ -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 => ({ EditEnum, EnumSetting, General, + Backup, Owners, CreateMixin, InviteSetting, diff --git a/plugins/setting-resources/src/types.ts b/plugins/setting-resources/src/types.ts new file mode 100644 index 0000000000..ab8d803c43 --- /dev/null +++ b/plugins/setting-resources/src/types.ts @@ -0,0 +1,51 @@ +/** + * @public + */ +export interface Snapshot { + added: Map + updated: Map + removed: string[] +} + +/** + * @public + */ +export interface SnapshotV6 { + added: Record + updated: Record + 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 + date: number +} + +/** + * @public + */ +export interface BackupInfo { + workspace: string + version: string + snapshots: BackupSnapshot[] + snapshotsIndex?: number + lastTxId?: string +} diff --git a/plugins/setting/src/index.ts b/plugins/setting/src/index.ts index 427b5222d9..d57a708edc 100644 --- a/plugins/setting/src/index.ts +++ b/plugins/setting/src/index.ts @@ -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, WorkspaceSetting: '' as Ref, ManageSpaces: '' as Ref, - Spaces: '' as Ref + Spaces: '' as Ref, + Backup: '' as Ref }, mixin: { Editable: '' as Ref>, @@ -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, OwnerPosition: '' as Ref, Value: '' as Ref + }, + metadata: { + BackupUrl: '' as Metadata } }) diff --git a/plugins/telegram-resources/src/components/Message.svelte b/plugins/telegram-resources/src/components/Message.svelte index 5e76c1e529..146cfd207e 100644 --- a/plugins/telegram-resources/src/components/Message.svelte +++ b/plugins/telegram-resources/src/components/Message.svelte @@ -15,7 +15,7 @@ --> diff --git a/pods/front/src/__start.ts b/pods/front/src/__start.ts index 3a691438d0..d4e44fa4a8 100644 --- a/pods/front/src/__start.ts +++ b/pods/front/src/__start.ts @@ -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 }) diff --git a/server/backup/src/backup.ts b/server/backup/src/backup.ts index dc2c4d544d..3b592fa46d 100644 --- a/server/backup/src/backup.ts +++ b/server/backup/src/backup.ts @@ -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) diff --git a/services/github/github-assets/lang/en.json b/services/github/github-assets/lang/en.json index 5fbb082ef4..67d1842b25 100644 --- a/services/github/github-assets/lang/en.json +++ b/services/github/github-assets/lang/en.json @@ -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" } } diff --git a/services/github/github-assets/lang/pt.json b/services/github/github-assets/lang/pt.json index 29058c8671..563b6f3c9c 100644 --- a/services/github/github-assets/lang/pt.json +++ b/services/github/github-assets/lang/pt.json @@ -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" } } diff --git a/services/github/github-assets/lang/ru.json b/services/github/github-assets/lang/ru.json index b913aeba60..cd73632bca 100644 --- a/services/github/github-assets/lang/ru.json +++ b/services/github/github-assets/lang/ru.json @@ -88,6 +88,7 @@ "AuthenticationRevokedGithub": "Требуется повторная авторизация для {login} для правильной работы приложения GitHub", "UnlinkInstallationTitle": "Удалить приложение Github", "UnlinkInstallation": "Вы уверены, что хотите удалить приложение GitHub? Синхронизация будет отключена.", - "RemoveInstallation": "Удалить" + "RemoveInstallation": "Удалить", + "Suspended": "Suspended" } } diff --git a/services/github/github-assets/lang/sp.json b/services/github/github-assets/lang/sp.json index 1848350fc3..e59b65df37 100644 --- a/services/github/github-assets/lang/sp.json +++ b/services/github/github-assets/lang/sp.json @@ -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" } } diff --git a/services/github/github-resources/src/components/GithubRepositories.svelte b/services/github/github-resources/src/components/GithubRepositories.svelte index 79e67994ee..018e39de53 100644 --- a/services/github/github-resources/src/components/GithubRepositories.svelte +++ b/services/github/github-resources/src/components/GithubRepositories.svelte @@ -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 export let projects: Project[] = [] @@ -141,145 +142,151 @@ } - - - -
- -
- {#if integration.name.length > 0} - {integration.name} - {#if (integration.type ?? '') !== ''} - ({integration.type}) - {/if} - {:else} -
-
- -
-
-
- {:else} - - {/if} -
-
-
- {repository.description ?? ''} -
- -
- {#if repository.language != null} -
-
- {repository.language} -
+ {:else} +
+ + + +
+
+
+ {:else} + + {/if} + + +
+ {repository.description ?? ''} +
+ +
+ {#if repository.language != null} +
+
+ {repository.language} +
+ {/if} + +
+ + {repository.stargazers ?? 0} +
+ +
+ + {repository.forks ?? 0} +
+ +
+ + {repository.openIssues ?? 0} +
+ + {#if repository.updatedAt !== undefined} +
+
+ {/if} +
+
+ {/each} + {#if repos.length > limit} +