Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artem Savchenko
2026-01-31 18:03:19 +07:00
33 changed files with 736 additions and 171 deletions
+27
View File
@@ -8067,9 +8067,18 @@ importers:
'@hcengineering/model-core':
specifier: workspace:^0.7.0
version: link:../core
'@hcengineering/model-notification':
specifier: workspace:^0.7.0
version: link:../notification
'@hcengineering/model-presentation':
specifier: workspace:^0.7.0
version: link:../presentation
'@hcengineering/model-view':
specifier: workspace:^0.7.0
version: link:../view
'@hcengineering/notification':
specifier: workspace:^0.7.0
version: link:../../plugins/notification
'@hcengineering/platform':
specifier: workspace:^0.7.19
version: link:../../foundations/core/packages/platform
@@ -8079,6 +8088,9 @@ importers:
'@hcengineering/ui':
specifier: workspace:^0.7.0
version: link:../../packages/ui
'@hcengineering/view':
specifier: workspace:^0.7.0
version: link:../../plugins/view
'@hcengineering/workbench':
specifier: workspace:^0.7.0
version: link:../../plugins/workbench
@@ -20563,6 +20575,9 @@ importers:
'@hcengineering/core':
specifier: workspace:^0.7.24
version: link:../../foundations/core/packages/core
'@hcengineering/notification':
specifier: workspace:^0.7.0
version: link:../notification
'@hcengineering/platform':
specifier: workspace:^0.7.19
version: link:../../foundations/core/packages/platform
@@ -20679,6 +20694,12 @@ importers:
'@hcengineering/login':
specifier: workspace:^0.7.0
version: link:../login
'@hcengineering/notification':
specifier: workspace:^0.7.0
version: link:../notification
'@hcengineering/panel':
specifier: workspace:^0.7.0
version: link:../../packages/panel
'@hcengineering/platform':
specifier: workspace:^0.7.19
version: link:../../foundations/core/packages/platform
@@ -20691,6 +20712,12 @@ importers:
'@hcengineering/ui':
specifier: workspace:^0.7.0
version: link:../../packages/ui
'@hcengineering/view':
specifier: workspace:^0.7.0
version: link:../view
'@hcengineering/view-resources':
specifier: workspace:^0.7.0
version: link:../view-resources
svelte:
specifier: ^4.2.20
version: 4.2.20
+2 -2
View File
@@ -1,7 +1,7 @@
{
"huly.local:8080": {
"title": "Huly",
"languages": "en,ru,pt,es,zh,fr,de,ja",
"languages": "en,ru,pt,es,zh,fr,de,ja,tr",
"defaultLanguage": "en",
"defaultApplication": "tracker",
"defaultSpace": "tracker:project:DefaultProject",
@@ -30,7 +30,7 @@
},
"huly.local:8087": {
"title": "Huly",
"languages": "en,ru,pt,es,zh,fr,de,ja",
"languages": "en,ru,pt,es,zh,fr,de,ja,tr",
"defaultLanguage": "en",
"defaultApplication": "tracker",
"defaultSpace": "tracker:project:DefaultProject",
+5 -1
View File
@@ -42,7 +42,11 @@
"@hcengineering/workbench": "workspace:^0.7.0",
"@hcengineering/presentation": "workspace:^0.7.0",
"@hcengineering/model-presentation": "workspace:^0.7.0",
"@hcengineering/model-view": "workspace:^0.7.0",
"@hcengineering/model-notification": "workspace:^0.7.0",
"@hcengineering/export-resources": "workspace:^0.7.0",
"@hcengineering/export": "workspace:^0.7.0"
"@hcengineering/export": "workspace:^0.7.0",
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/notification": "workspace:^0.7.0"
}
}
+76 -6
View File
@@ -13,24 +13,94 @@
// limitations under the License.
//
import { type Builder } from '@hcengineering/model'
import core from '@hcengineering/model-core'
import type { Class, Doc, Domain, Ref } from '@hcengineering/core'
import type { ExportResultRecord } from '@hcengineering/export'
import { type Builder, Model, Prop, ArrOf, TypeRef, TypeString, TypeNumber, UX } from '@hcengineering/model'
import core, { TDoc } from '@hcengineering/model-core'
import presentation from '@hcengineering/model-presentation'
import workbench from '@hcengineering/workbench'
import exportPlugin from './plugin'
import view from '@hcengineering/model-view'
import notification from '@hcengineering/notification'
import exportPlugin from '@hcengineering/export'
import exportModelPlugin from './plugin'
export { exportId } from '@hcengineering/export'
export * from './migration'
export default exportPlugin
export default exportModelPlugin
export const DOMAIN_EXPORT = 'export' as Domain
@Model(exportPlugin.class.ExportResultRecord, core.class.Doc, DOMAIN_EXPORT)
@UX(exportPlugin.string.ImportCompleted, exportPlugin.icon.Export)
export class TExportResultRecord extends TDoc implements ExportResultRecord {
@Prop(TypeString(), exportPlugin.string.SourceWorkspace)
sourceWorkspace!: string
@Prop(TypeString(), exportPlugin.string.TargetWorkspace)
targetWorkspace!: string
@Prop(TypeNumber(), exportPlugin.string.ExportedCount)
exportedCount!: number
@Prop(ArrOf(TypeRef(core.class.Doc)), exportPlugin.string.ExportedDocumentIds)
exportedDocumentIds!: Ref<Doc>[]
@Prop(TypeRef(core.class.Class), exportPlugin.string.ExportedDocumentClass)
objectClass!: Ref<Class<Doc>>
@Prop(TypeString(), view.string.Title)
title?: string
}
export function createModel (builder: Builder): void {
builder.createModel(TExportResultRecord)
builder.mixin(exportPlugin.class.ExportResultRecord, core.class.Class, view.mixin.ObjectPresenter, {
presenter: view.component.BaseDocPresenter
})
builder.mixin(exportPlugin.class.ExportResultRecord, core.class.Class, view.mixin.ObjectPanel, {
component: exportPlugin.component.ExportResultPanel
})
builder.mixin(exportPlugin.class.ExportResultRecord, core.class.Class, view.mixin.ObjectTitle, {
titleProvider: exportPlugin.function.ExportResultTitleProvider
})
builder.createDoc(
notification.class.NotificationGroup,
core.space.Model,
{
label: exportPlugin.string.Import,
icon: exportPlugin.icon.Export,
objectClass: exportPlugin.class.ExportResultRecord
},
exportPlugin.ids.ImportNotificationGroup
)
builder.createDoc(
notification.class.NotificationType,
core.space.Model,
{
hidden: false,
generated: false,
label: exportPlugin.string.ImportedDocuments,
group: exportPlugin.ids.ImportNotificationGroup,
txClasses: [],
objectClass: exportPlugin.class.ExportResultRecord,
defaultEnabled: true
},
exportPlugin.ids.ImportedDocumentsNotification
)
builder.createDoc(
presentation.class.ComponentPointExtension,
core.space.Model,
{
extension: workbench.extensions.SpecialViewAction,
component: exportPlugin.component.ExportButton
component: exportModelPlugin.component.ExportButton
},
exportPlugin.extensions.ExportButton
exportModelPlugin.extensions.ExportButton
)
}
@@ -15,10 +15,11 @@
import { type Class, type Doc, type Hierarchy, type Ref } from '@hcengineering/core'
import { translate, type IntlString } from '@hcengineering/platform'
import cardPlugin, { type CardSpace } from '@hcengineering/card'
import cardPlugin, { type Card, type CardSpace } from '@hcengineering/card'
import { type AttributeModel } from '@hcengineering/view'
import { getClient } from '@hcengineering/presentation'
import { isIntlString } from '@hcengineering/converter-resources'
import { getCardIds, getCardVersion } from './cardUtils'
/**
* Cache for MasterTag ID -> label mappings to reduce database calls
@@ -110,6 +111,18 @@ export async function formatCardValue (
const cardDoc = card as unknown as Record<string, unknown>
if (attr.key === '') {
const labelStr = typeof attr.label === 'string' ? attr.label : ''
if (labelStr.startsWith('custom') || attr.isLookup) {
return undefined
}
const cardObj = card as unknown as Card
const ids = getCardIds(cardObj, hierarchy)
const version = getCardVersion(cardObj, hierarchy)
const parts = [ids, cardObj.title, version].filter(Boolean)
return parts.join(' ')
}
// Handle _class field (MasterTag/Type) - format MasterTag ID to label
if (attr.key === '_class') {
const classValue: unknown = cardDoc._class
+55
View File
@@ -0,0 +1,55 @@
//
// Copyright © 2026 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 core, { type Hierarchy, toRank } from '@hcengineering/core'
import { type Card } from '@hcengineering/card'
/**
* Get the IDs string for a card (attributes with showInPresenter, sorted by rank).
* Same logic as CardPresenter title line prefix.
*/
export function getCardIds (object: Card | undefined, hierarchy: Hierarchy): string {
if (object === undefined) return ''
const attrs = [...hierarchy.getAllAttributes(object._class, core.class.Doc).values()].sort((a, b) => {
const rankA = a.rank ?? toRank(a._id) ?? ''
const rankB = b.rank ?? toRank(b._id) ?? ''
return rankA.localeCompare(rankB)
})
const res: string[] = []
for (const attr of attrs) {
const val = (object as any)[attr.name]
if ((attr as { showInPresenter?: boolean }).showInPresenter === true && val !== undefined) {
if (typeof val === 'string' || typeof val === 'number') {
res.push(val.toString())
} else if (typeof val === 'boolean') {
res.push(val ? '✅' : '❌️')
}
}
}
return res.join(' ')
}
/**
* Get the version string for a card (e.g. "v1") when VersionableClass is enabled.
* Same logic as CardPresenter title line suffix.
*/
export function getCardVersion (val: Card | undefined, hierarchy: Hierarchy): string {
if (val === undefined) return ''
const mixin = hierarchy.classHierarchyMixin(val._class, core.mixin.VersionableClass)
if (mixin != null && mixin.enabled) {
return 'v' + (val.version ?? 1)
}
return ''
}
@@ -14,7 +14,7 @@
-->
<script lang="ts">
import { Card, MasterTag } from '@hcengineering/card'
import core, { Ref, toRank } from '@hcengineering/core'
import { Ref } from '@hcengineering/core'
import { Asset, getEmbeddedLabel } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { AnySvelteComponent, tooltip } from '@hcengineering/ui'
@@ -22,6 +22,7 @@
import { DocNavLink, ObjectMention } from '@hcengineering/view-resources'
import card from '../plugin'
import { getCardIds, getCardVersion } from '../cardUtils'
import CardIcon from './CardIcon.svelte'
import ParentNamesPresenter from './ParentNamesPresenter.svelte'
@@ -56,41 +57,8 @@
$: _class = cardObj && (client.getHierarchy().getClass(cardObj?._class) as MasterTag)
$: icon = _class && _class.icon
$: ids = getIds(cardObj)
function getIds (object: Card | undefined): string {
if (object === undefined) return ''
const h = client.getHierarchy()
const attrs = [...h.getAllAttributes(object._class, core.class.Doc).values()].sort((a, b) => {
const rankA = a.rank ?? toRank(a._id) ?? ''
const rankB = b.rank ?? toRank(b._id) ?? ''
return rankA.localeCompare(rankB)
})
const res: string[] = []
for (const attr of attrs) {
const val = (object as any)[attr.name]
if (attr.showInPresenter === true && val !== undefined) {
if (typeof val === 'string' || typeof val === 'number') {
res.push(val.toString())
} else if (typeof val === 'boolean') {
res.push(val ? '✅' : '❌️')
}
}
}
return res.join(' ')
}
$: version = getVersion(cardObj)
function getVersion (val: Card | undefined): string {
if (val === undefined) return ''
const h = client.getHierarchy()
const mixin = h.classHierarchyMixin(val._class, core.mixin.VersionableClass)
if (mixin?.enabled) {
return 'v' + (val.version ?? 1)
}
return ''
}
$: ids = getCardIds(cardObj, client.getHierarchy())
$: version = getCardVersion(cardObj, client.getHierarchy())
</script>
{#if inline && cardObj}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "Export zahájen. Až bude dokončen, obdržíte oznámení.",
"ExportToWorkspaceCompleted": "Export dokončen",
"ExportToWorkspaceCompletedMessage": "Export byl úspěšně dokončen.",
"ImportToWorkspaceNotificationMessage": "{count} dokumentů importováno do vašeho pracovního prostoru",
"ImportCompleted": "Dokumenty importovány do pracovního prostoru",
"SourceWorkspace": "Zdrojový pracovní prostor",
"ExportedCount": "Počet exportovaných",
"ExportedDocumentIds": "ID exportovaných dokumentů",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 dokument byl importován z pracovního prostoru {workspace}} other {# dokumentů bylo importováno z pracovního prostoru {workspace}}}:",
"ExportedDocumentClass": "Třída exportovaných dokumentů",
"Import": "Import",
"ImportedDocuments": "Nové dokumenty",
"ExportToWorkspaceFailed": "Export se nezdařil",
"SelectWorkspace": "Vybrat pracovní prostor",
"SelectWorkspaceToExport": "Vybrat pracovní prostor pro export {count, plural, =1 {1 dokument} other {# dokumentů}}",
@@ -38,6 +47,12 @@
"SelectSpace": "Vybrat prostor",
"NoSelectedDocuments": "Žádné dokumenty nevybrány",
"RequestPermissionToImport": "Prosím, požádejte vlastníka cílového pracovního prostoru o povolení k importu dokumentů.",
"SkipDeletedObsolete": "Přeskočit archivované nebo zastaralé dokumenty"
"SkipDeletedObsolete": "Přeskočit archivované nebo zastaralé dokumenty",
"ExportOnlyEffective": "Exportovat pouze dokumenty s platným stavem",
"ExportFilterMode": "Dokumenty k exportu",
"ExportFilterEffectiveOnly": "Pouze platné dokumenty",
"ExportFilterSkipArchivedObsolete": "Vše kromě archivovaných a zastaralých",
"ExportFilterAll": "Všechny dokumenty",
"ExportResultRecordTitle": "Export dokumentů z pracovního prostoru {workspace} ({date})"
}
}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "Export gestartet. Sie erhalten eine Benachrichtigung, wenn der Export abgeschlossen ist.",
"ExportToWorkspaceCompleted": "Export abgeschlossen",
"ExportToWorkspaceCompletedMessage": "Export erfolgreich abgeschlossen.",
"ImportToWorkspaceNotificationMessage": "{count} Dokumente in Ihren Arbeitsbereich importiert",
"ImportCompleted": "Dokumente in Arbeitsbereich importiert",
"SourceWorkspace": "Quell-Arbeitsbereich",
"ExportedCount": "Exportierte Anzahl",
"ExportedDocumentIds": "IDs exportierter Dokumente",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 Dokument wurde aus dem Arbeitsbereich {workspace} importiert} other {# Dokumente wurden aus dem Arbeitsbereich {workspace} importiert}}:",
"ExportedDocumentClass": "Klasse exportierter Dokumente",
"Import": "Import",
"ImportedDocuments": "Neue Dokumente",
"ExportToWorkspaceFailed": "Export fehlgeschlagen",
"SelectWorkspace": "Arbeitsbereich auswählen",
"SelectWorkspaceToExport": "Arbeitsbereich zum Exportieren von {count, plural, =1 {1 Dokument} other {# Dokumenten}} auswählen",
@@ -38,6 +47,12 @@
"SelectSpace": "Bereich auswählen",
"NoSelectedDocuments": "Keine Dokumente ausgewählt",
"RequestPermissionToImport": "Bitte fordern Sie die Berechtigung vom Eigentümer des Ziel-Arbeitsbereichs an, um Dokumente zu importieren.",
"SkipDeletedObsolete": "Archivierte oder veraltete Dokumente überspringen"
"SkipDeletedObsolete": "Archivierte oder veraltete Dokumente überspringen",
"ExportOnlyEffective": "Nur Dokumente mit wirksamem Status exportieren",
"ExportFilterMode": "Zu exportierende Dokumente",
"ExportFilterEffectiveOnly": "Nur wirksame Dokumente exportieren",
"ExportFilterSkipArchivedObsolete": "Alle Dokumente außer archivierten und veralteten",
"ExportFilterAll": "Alle Dokumente",
"ExportResultRecordTitle": "Export von Dokumenten aus dem Arbeitsbereich {workspace} ({date})"
}
}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "Export started. You will receive a notification when it completes.",
"ExportToWorkspaceCompleted": "Export completed",
"ExportToWorkspaceCompletedMessage": "Export completed successfully.",
"ImportToWorkspaceNotificationMessage": "{count} documents imported to your workspace",
"ImportCompleted": "Documents imported to workspace",
"SourceWorkspace": "Source workspace",
"ExportedCount": "Exported count",
"ExportedDocumentIds": "Exported document IDs",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 document was imported from {workspace} workspace} other {# documents were imported from {workspace} workspace}}:",
"ExportedDocumentClass": "Exported document class",
"Import": "Import",
"ImportedDocuments": "New documents",
"ExportToWorkspaceFailed": "Export failed",
"SelectWorkspace": "Select workspace",
"SelectWorkspaceToExport": "Select workspace to export {count, plural, =1 {1 document} other {# documents}}",
@@ -38,6 +47,12 @@
"SelectSpace": "Select space",
"NoSelectedDocuments": "No documents selected",
"RequestPermissionToImport": "Please request permission from the target workspace owner to import documents.",
"SkipDeletedObsolete": "Skip archived or obsolete documents"
"SkipDeletedObsolete": "Skip archived or obsolete documents",
"ExportOnlyEffective": "Export only documents with effective status",
"ExportFilterMode": "Documents to export",
"ExportFilterEffectiveOnly": "Export only effective documents",
"ExportFilterSkipArchivedObsolete": "All documents except archived and outdated",
"ExportFilterAll": "All documents",
"ExportResultRecordTitle": "Export of documents from {workspace} workspace ({date})"
}
}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "Exportación iniciada. Recibirá una notificación cuando se complete.",
"ExportToWorkspaceCompleted": "Exportación completada",
"ExportToWorkspaceCompletedMessage": "Exportación completada exitosamente.",
"ImportToWorkspaceNotificationMessage": "{count} documentos importados a su espacio de trabajo",
"ImportCompleted": "Documentos importados al espacio de trabajo",
"SourceWorkspace": "Espacio de trabajo de origen",
"ExportedCount": "Cantidad exportada",
"ExportedDocumentIds": "ID de documentos exportados",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 documento fue importado desde el espacio de trabajo {workspace}} other {# documentos fueron importados desde el espacio de trabajo {workspace}}}:",
"ExportedDocumentClass": "Clase de documentos exportados",
"Import": "Importar",
"ImportedDocuments": "Documentos nuevos",
"ExportToWorkspaceFailed": "Exportación fallida",
"SelectWorkspace": "Seleccionar espacio de trabajo",
"SelectWorkspaceToExport": "Seleccionar espacio de trabajo para exportar {count, plural, =1 {1 documento} other {# documentos}}",
@@ -38,6 +47,12 @@
"SelectSpace": "Seleccionar espacio",
"NoSelectedDocuments": "No hay documentos seleccionados",
"RequestPermissionToImport": "Por favor, solicite permiso al propietario del espacio de trabajo de destino para importar documentos.",
"SkipDeletedObsolete": "Omitir documentos archivados u obsoletos"
"SkipDeletedObsolete": "Omitir documentos archivados u obsoletos",
"ExportOnlyEffective": "Exportar solo documentos con estado efectivo",
"ExportFilterMode": "Documentos a exportar",
"ExportFilterEffectiveOnly": "Solo documentos efectivos",
"ExportFilterSkipArchivedObsolete": "Todos excepto archivados y obsoletos",
"ExportFilterAll": "Todos los documentos",
"ExportResultRecordTitle": "Exportación de documentos del espacio de trabajo {workspace} ({date})"
}
}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "Exportation démarrée. Vous recevrez une notification une fois terminée.",
"ExportToWorkspaceCompleted": "Exportation terminée",
"ExportToWorkspaceCompletedMessage": "Exportation terminée avec succès.",
"ImportToWorkspaceNotificationMessage": "{count} documents importés dans votre espace de travail",
"ImportCompleted": "Documents importés dans l'espace de travail",
"SourceWorkspace": "Espace de travail source",
"ExportedCount": "Nombre exporté",
"ExportedDocumentIds": "ID des documents exportés",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 document a été importé depuis l'espace de travail {workspace}} other {# documents ont été importés depuis l'espace de travail {workspace}}}:",
"ExportedDocumentClass": "Classe des documents exportés",
"Import": "Import",
"ImportedDocuments": "Nouveaux documents",
"ExportToWorkspaceFailed": "Exportation échouée",
"SelectWorkspace": "Sélectionner l'espace de travail",
"SelectWorkspaceToExport": "Sélectionner l'espace de travail pour exporter {count, plural, =1 {1 document} other {# documents}}",
@@ -38,6 +47,12 @@
"SelectSpace": "Sélectionner l'espace",
"NoSelectedDocuments": "Aucun document sélectionné",
"RequestPermissionToImport": "Veuillez demander l'autorisation au propriétaire de l'espace de travail cible pour importer des documents.",
"SkipDeletedObsolete": "Ignorer les documents archivés ou obsolètes"
"SkipDeletedObsolete": "Ignorer les documents archivés ou obsolètes",
"ExportOnlyEffective": "Exporter uniquement les documents avec statut effectif",
"ExportFilterMode": "Documents à exporter",
"ExportFilterEffectiveOnly": "Seulement les documents effectifs",
"ExportFilterSkipArchivedObsolete": "Tous sauf archivés et obsolètes",
"ExportFilterAll": "Tous les documents",
"ExportResultRecordTitle": "Export de documents depuis l'espace de travail {workspace} ({date})"
}
}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "Esportazione avviata. Riceverai una notifica quando sarà completata.",
"ExportToWorkspaceCompleted": "Esportazione completata",
"ExportToWorkspaceCompletedMessage": "Esportazione completata con successo.",
"ImportToWorkspaceNotificationMessage": "{count} documenti importati nel tuo spazio di lavoro",
"ImportCompleted": "Documenti importati nell'area di lavoro",
"SourceWorkspace": "Spazio di lavoro di origine",
"ExportedCount": "Conteggio esportato",
"ExportedDocumentIds": "ID documenti esportati",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 documento è stato importato dallo spazio di lavoro {workspace}} other {# documenti sono stati importati dallo spazio di lavoro {workspace}}}:",
"ExportedDocumentClass": "Classe documenti esportati",
"Import": "Importa",
"ImportedDocuments": "Nuovi documenti",
"ExportToWorkspaceFailed": "Esportazione fallita",
"SelectWorkspace": "Seleziona spazio di lavoro",
"SelectWorkspaceToExport": "Seleziona spazio di lavoro per esportare {count, plural, =1 {1 documento} other {# documenti}}",
@@ -38,6 +47,12 @@
"SelectSpace": "Seleziona spazio",
"NoSelectedDocuments": "Nessun documento selezionato",
"RequestPermissionToImport": "Si prega di richiedere l'autorizzazione al proprietario dello spazio di lavoro di destinazione per importare documenti.",
"SkipDeletedObsolete": "Salta documenti archivati o obsoleti"
"SkipDeletedObsolete": "Salta documenti archivati o obsoleti",
"ExportOnlyEffective": "Esporta solo documenti con stato effettivo",
"ExportFilterMode": "Documenti da esportare",
"ExportFilterEffectiveOnly": "Solo documenti effettivi",
"ExportFilterSkipArchivedObsolete": "Tutti tranne archiviati e obsoleti",
"ExportFilterAll": "Tutti i documenti",
"ExportResultRecordTitle": "Esportazione di documenti dallo spazio di lavoro {workspace} ({date})"
}
}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "エクスポートを開始しました。完了すると通知が届きます。",
"ExportToWorkspaceCompleted": "エクスポート完了",
"ExportToWorkspaceCompletedMessage": "エクスポートが正常に完了しました。",
"ImportToWorkspaceNotificationMessage": "{count} 件のドキュメントがワークスペースにインポートされました",
"ImportCompleted": "ドキュメントがワークスペースにインポートされました",
"SourceWorkspace": "ソースワークスペース",
"ExportedCount": "エクスポート数",
"ExportedDocumentIds": "エクスポートされたドキュメントのID",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 件のドキュメントがワークスペース {workspace} からインポートされました} other {# 件のドキュメントがワークスペース {workspace} からインポートされました}}:",
"ExportedDocumentClass": "エクスポートされたドキュメントのクラス",
"Import": "インポート",
"ImportedDocuments": "新しいドキュメント",
"ExportToWorkspaceFailed": "エクスポート失敗",
"SelectWorkspace": "ワークスペースを選択",
"SelectWorkspaceToExport": "{count, plural, =1 {1 件のドキュメント} other {# 件のドキュメント}}をエクスポートするワークスペースを選択",
@@ -38,6 +47,12 @@
"SelectSpace": "スペースを選択",
"NoSelectedDocuments": "ドキュメントが選択されていません",
"RequestPermissionToImport": "ドキュメントをインポートするには、対象ワークスペースの所有者に許可をリクエストしてください。",
"SkipDeletedObsolete": "アーカイブ済みまたは廃止されたドキュメントをスキップ"
"SkipDeletedObsolete": "アーカイブ済みまたは廃止されたドキュメントをスキップ",
"ExportOnlyEffective": "有効なステータスのドキュメントのみエクスポート",
"ExportFilterMode": "エクスポートするドキュメント",
"ExportFilterEffectiveOnly": "有効なドキュメントのみ",
"ExportFilterSkipArchivedObsolete": "アーカイブ・廃止を除くすべて",
"ExportFilterAll": "すべてのドキュメント",
"ExportResultRecordTitle": "ワークスペース {workspace} からのドキュメントのエクスポート ({date})"
}
}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "Exportação iniciada. Você receberá uma notificação quando for concluída.",
"ExportToWorkspaceCompleted": "Exportação concluída",
"ExportToWorkspaceCompletedMessage": "Exportação concluída com sucesso.",
"ImportToWorkspaceNotificationMessage": "{count} documentos importados para o seu espaço de trabalho",
"ImportCompleted": "Documentos importados para o espaço de trabalho",
"SourceWorkspace": "Espaço de trabalho de origem",
"ExportedCount": "Quantidade exportada",
"ExportedDocumentIds": "IDs dos documentos exportados",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 documento foi importado do espaço de trabalho {workspace}} other {# documentos foram importados do espaço de trabalho {workspace}}}:",
"ExportedDocumentClass": "Classe dos documentos exportados",
"Import": "Importar",
"ImportedDocuments": "Novos documentos",
"ExportToWorkspaceFailed": "Exportação falhou",
"SelectWorkspace": "Selecionar espaço de trabalho",
"SelectWorkspaceToExport": "Selecionar espaço de trabalho para exportar {count, plural, =1 {1 documento} other {# documentos}}",
@@ -38,6 +47,12 @@
"SelectSpace": "Selecionar espaço",
"NoSelectedDocuments": "Nenhum documento selecionado",
"RequestPermissionToImport": "Por favor, solicite permissão ao proprietário do espaço de trabalho de destino para importar documentos.",
"SkipDeletedObsolete": "Pular documentos arquivados ou obsoletos"
"SkipDeletedObsolete": "Pular documentos arquivados ou obsoletos",
"ExportOnlyEffective": "Exportar apenas documentos com status efetivo",
"ExportFilterMode": "Documentos a exportar",
"ExportFilterEffectiveOnly": "Apenas documentos efetivos",
"ExportFilterSkipArchivedObsolete": "Todos exceto arquivados e obsoletos",
"ExportFilterAll": "Todos os documentos",
"ExportResultRecordTitle": "Exportação de documentos do espaço de trabalho {workspace} ({date})"
}
}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "Экспорт начат. Вы получите уведомление, когда он будет завершен.",
"ExportToWorkspaceCompleted": "Экспорт завершен",
"ExportToWorkspaceCompletedMessage": "Экспорт успешно завершен.",
"ImportToWorkspaceNotificationMessage": "{count} документов импортировано в ваше рабочее пространство",
"ImportCompleted": "Документы импортированы в рабочее пространство",
"SourceWorkspace": "Исходное рабочее пространство",
"ExportedCount": "Количество экспортированных",
"ExportedDocumentIds": "ID экспортированных документов",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 документ импортирован из рабочего пространства {workspace}} few {# документа импортированы из рабочего пространства {workspace}} other {# документов импортировано из рабочего пространства {workspace}}}:",
"ExportedDocumentClass": "Класс экспортированных документов",
"Import": "Импорт",
"ImportedDocuments": "Новые документы",
"ExportToWorkspaceFailed": "Экспорт не удался",
"SelectWorkspace": "Выбрать рабочее пространство",
"SelectWorkspaceToExport": "Выбрать рабочее пространство для экспорта {count, plural, =1 {1 документ} one {# документ} few {# документа} other {# документов}}",
@@ -38,6 +47,12 @@
"SelectSpace": "Выбрать пространство",
"NoSelectedDocuments": "Документы не выбраны",
"RequestPermissionToImport": "Пожалуйста, запросите разрешение у владельца рабочего пространства, куда вы хотите экспортировать документы.",
"SkipDeletedObsolete": "Пропускать архивированные или устаревшие документы"
"SkipDeletedObsolete": "Пропускать архивированные или устаревшие документы",
"ExportOnlyEffective": "Экспортировать только документы с действующим статусом",
"ExportFilterMode": "Документы для экспорта",
"ExportFilterEffectiveOnly": "Только действующие документы",
"ExportFilterSkipArchivedObsolete": "Все кроме архивных и устаревших",
"ExportFilterAll": "Все документы",
"ExportResultRecordTitle": "Экспорт документов из пространства {workspace} ({date})"
}
}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "Dışa aktarma başladı. Tamamlandığında bir bildirim alacaksınız.",
"ExportToWorkspaceCompleted": "Dışa aktarma tamamlandı",
"ExportToWorkspaceCompletedMessage": "Dışa aktarma başarıyla tamamlandı.",
"ImportToWorkspaceNotificationMessage": "{count} belge çalışma alanınıza içe aktarıldı",
"ImportCompleted": "Belgeler çalışma alanına içe aktarıldı",
"SourceWorkspace": "Kaynak çalışma alanı",
"ExportedCount": "Dışa aktarılan sayı",
"ExportedDocumentIds": "Dışa aktarılan belge kimlikleri",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 belge {workspace} çalışma alanından içe aktarıldı} other {# belge {workspace} çalışma alanından içe aktarıldı}}:",
"ExportedDocumentClass": "Dışa aktarılan belge sınıfı",
"Import": "İçe aktarma",
"ImportedDocuments": "Yeni belgeler",
"ExportToWorkspaceFailed": "Dışa aktarma başarısız oldu",
"SelectWorkspace": "Çalışma alanı seç",
"SelectWorkspaceToExport": "{count, plural, =1 {1 belgeyi} other {# belgeyi}} dışa aktarmak için çalışma alanı seç",
@@ -38,6 +47,12 @@
"SelectSpace": "Alan seç",
"NoSelectedDocuments": "Seçili belge yok",
"RequestPermissionToImport": "Belgeleri içe aktarmak için lütfen hedef çalışma alanı sahibinden izin isteyin.",
"SkipDeletedObsolete": "Arşivlenmiş veya kullanımdan kaldırılmış belgeleri atla"
"SkipDeletedObsolete": "Arşivlenmiş veya kullanımdan kaldırılmış belgeleri atla",
"ExportOnlyEffective": "Yalnızca geçerli durumdaki belgeleri dışa aktar",
"ExportFilterMode": "Dışa aktarılacak belgeler",
"ExportFilterEffectiveOnly": "Yalnızca geçerli belgeler",
"ExportFilterSkipArchivedObsolete": "Arşivlenmiş ve kullanımdan kaldırılmış hariç tümü",
"ExportFilterAll": "Tüm belgeler",
"ExportResultRecordTitle": "{workspace} çalışma alanından belge dışa aktarma ({date})"
}
}
+16 -1
View File
@@ -31,6 +31,15 @@
"ExportStartedMessage": "导出已开始。完成后您将收到通知。",
"ExportToWorkspaceCompleted": "导出完成",
"ExportToWorkspaceCompletedMessage": "导出已成功完成。",
"ImportToWorkspaceNotificationMessage": "{count} 个文档已导入到您的工作区",
"ImportCompleted": "文档已导入到工作区",
"SourceWorkspace": "源工作区",
"ExportedCount": "导出数量",
"ExportedDocumentIds": "已导出文档的 ID",
"DocumentsImportedFromWorkspace": "{count, plural, one {1 个文档已从工作区 {workspace} 导入} other {# 个文档已从工作区 {workspace} 导入}}:",
"ExportedDocumentClass": "已导出文档的类",
"Import": "导入",
"ImportedDocuments": "新文档",
"ExportToWorkspaceFailed": "导出失败",
"SelectWorkspace": "选择工作区",
"SelectWorkspaceToExport": "选择工作区以导出 {count, plural, =1 {1 文档} other {# 个文档}}",
@@ -38,6 +47,12 @@
"SelectSpace": "选择空间",
"NoSelectedDocuments": "未选择文档",
"RequestPermissionToImport": "请向目标工作区所有者请求导入文档的权限。",
"SkipDeletedObsolete": "跳过已归档或已过时的文档"
"SkipDeletedObsolete": "跳过已归档或已过时的文档",
"ExportOnlyEffective": "仅导出具有有效状态的文档",
"ExportFilterMode": "要导出的文档",
"ExportFilterEffectiveOnly": "仅有效文档",
"ExportFilterSkipArchivedObsolete": "除已归档和已过时外的全部",
"ExportFilterAll": "全部文档",
"ExportResultRecordTitle": "从工作区 {workspace} 导出文档 ({date})"
}
}
+5 -1
View File
@@ -44,6 +44,10 @@
"@hcengineering/ui": "workspace:^0.7.0",
"@hcengineering/export": "workspace:^0.7.0",
"@hcengineering/login": "workspace:^0.7.0",
"@hcengineering/theme": "workspace:^0.7.0"
"@hcengineering/notification": "workspace:^0.7.0",
"@hcengineering/panel": "workspace:^0.7.0",
"@hcengineering/theme": "workspace:^0.7.0",
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/view-resources": "workspace:^0.7.0"
}
}
@@ -0,0 +1,142 @@
<!--
// 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 core, { type Class, type Doc, type Ref } from '@hcengineering/core'
import exportPlugin, { type ExportResultRecord } from '@hcengineering/export'
import notification from '@hcengineering/notification'
import { Panel } from '@hcengineering/panel'
import { getResource } from '@hcengineering/platform'
import { createQuery } from '@hcengineering/presentation'
import { Button, IconMoreH, Label } from '@hcengineering/ui'
import { DocNavLink, ObjectPresenter, showMenu } from '@hcengineering/view-resources'
import view from '@hcengineering/view'
import { createEventDispatcher, onDestroy } from 'svelte'
export let _id: Ref<ExportResultRecord>
export let _class: Ref<Class<ExportResultRecord>>
export let embedded: boolean = false
const query = createQuery()
const docsQuery = createQuery()
const inboxClient = getResource(notification.function.GetInboxNotificationsClient).then((res) => res())
const dispatch = createEventDispatcher()
let object: ExportResultRecord | undefined = undefined
let lastId: Ref<Doc> | undefined = undefined
let exportedDocs: Doc[] = []
$: query.query(_class, { _id }, (result) => {
object = result[0]
})
$: docClass = object?.objectClass ?? core.class.Doc
$: hasExportedDocs = (object?.exportedDocumentIds?.length ?? 0) > 0
$: if (hasExportedDocs && object != null) {
docsQuery.query(docClass, { _id: { $in: object.exportedDocumentIds } }, (result) => {
exportedDocs = result
})
} else {
docsQuery.unsubscribe()
exportedDocs = []
}
$: if (object?._id !== lastId) {
const prev = lastId
lastId = object?._id
if (prev !== undefined) {
void inboxClient.then((c) => {
void c.readDoc(prev)
})
}
}
onDestroy(() => {
void inboxClient.then((c) => {
if (_id !== undefined) void c.readDoc(_id)
})
})
</script>
{#if object}
<Panel
isHeader={false}
isSub={false}
isAside={true}
{embedded}
{object}
on:open
on:close={() => dispatch('close')}
withoutInput
withoutActivity
>
<svelte:fragment slot="title">
<DocNavLink noUnderline {object}>
<div class="title">
{object.title ??
`Import completed ${object.exportedCount} document${object.exportedCount !== 1 ? 's' : ''}`}
</div>
</DocNavLink>
</svelte:fragment>
<svelte:fragment slot="utils">
<Button
icon={IconMoreH}
iconProps={{ size: 'medium' }}
kind={'icon'}
dataId="btnMoreActions"
on:click={(e) => {
showMenu(e, { object, excludedActions: [view.action.Open] })
}}
/>
</svelte:fragment>
<div class="export-result-content flex-col flex-grow flex-no-shrink step-tb-6">
<p class="export-result-summary">
<Label
label={exportPlugin.string.DocumentsImportedFromWorkspace}
params={{ count: object.exportedCount, workspace: object.sourceWorkspace }}
/>
</p>
{#if exportedDocs.length > 0}
<div class="doc-links">
{#each exportedDocs as doc (doc._id)}
<DocNavLink noUnderline object={doc}>
<ObjectPresenter
_class={docClass}
value={doc}
props={{ inline: true, size: 'small', withIcon: true, isGray: true }}
/>
</DocNavLink>
{/each}
</div>
{/if}
</div>
</Panel>
{/if}
<style lang="scss">
.export-result-content {
padding: 0 1rem;
}
.export-result-summary {
margin: 0 0 0.75rem;
}
.doc-links {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 0.25rem 1rem;
}
</style>
@@ -25,10 +25,10 @@
type Class
} from '@hcengineering/core'
import { Card, getCurrentWorkspaceUuid } from '@hcengineering/presentation'
import { CheckBox, DropdownLabels, Label } from '@hcengineering/ui'
import { DropdownLabels, DropdownLabelsIntl, Label } from '@hcengineering/ui'
import { getResource } from '@hcengineering/platform'
import login from '@hcengineering/login'
import { type RelationDefinition, shouldSkipDocument } from '@hcengineering/export'
import { type RelationDefinition, shouldSkipDocument, isEffectiveDocument } from '@hcengineering/export'
import { createEventDispatcher } from 'svelte'
@@ -48,15 +48,27 @@
let workspacesWithPermission = new Set<WorkspaceUuid>()
let loading = false
let workspaceLoading = false
let skipDeletedObsolete = true
type ExportFilterMode = 'effectiveOnly' | 'skipArchivedObsolete' | 'all'
let exportFilterMode: ExportFilterMode = 'effectiveOnly'
const exportFilterItems = [
{ id: 'effectiveOnly' as const, label: plugin.string.ExportFilterEffectiveOnly },
{ id: 'skipArchivedObsolete' as const, label: plugin.string.ExportFilterSkipArchivedObsolete },
{ id: 'all' as const, label: plugin.string.ExportFilterAll }
]
$: selectedDocs = spaceExport !== true ? (Array.isArray(value) ? value : value != null ? [value] : []) : []
$: _class = docClass ?? (selectedDocs.length > 0 ? selectedDocs[0]._class : undefined)
$: filteredSelectedDocs =
skipDeletedObsolete && selectedDocs.length > 0
? selectedDocs.filter((doc) => !shouldSkipDocument(doc))
: selectedDocs
function filterDocsForExport (docs: Doc[], exportFilterMode: ExportFilterMode): Doc[] {
if (docs.length === 0) return docs
if (exportFilterMode === 'effectiveOnly') return docs.filter((doc) => isEffectiveDocument(doc))
if (exportFilterMode === 'skipArchivedObsolete') return docs.filter((doc) => !shouldSkipDocument(doc))
return docs
}
$: filteredSelectedDocs = filterDocsForExport(selectedDocs, exportFilterMode)
// Build query with space filter when exporting from space
$: exportQuery = spaceExport === true ? { ...(query ?? {}), space: (value as Space)._id } : query
@@ -103,13 +115,22 @@
})
)
$: canSave = targetWorkspace !== undefined && _class != null && filteredSelectedDocs.length > 0
$: canSave =
targetWorkspace !== undefined && _class != null && (spaceExport === true || filteredSelectedDocs.length > 0)
async function handleExport (): Promise<void> {
if (!canSave || _class == null) return
loading = true
void exportToWorkspace(_class, exportQuery, filteredSelectedDocs, targetWorkspace, relations, skipDeletedObsolete)
void exportToWorkspace(
_class,
exportQuery,
filteredSelectedDocs,
targetWorkspace,
relations,
exportFilterMode === 'skipArchivedObsolete',
exportFilterMode === 'effectiveOnly'
)
loading = false
dispatch('close', true)
}
@@ -146,11 +167,9 @@
kind="regular"
size="large"
/>
<div class="flex gap-2 pt-4">
<CheckBox bind:checked={skipDeletedObsolete} />
<div class="secondary-textColor">
<Label label={plugin.string.SkipDeletedObsolete} />
</div>
</div>
<span class="pl-2 py-4 secondary-textColor">
<Label label={plugin.string.ExportFilterMode} />
</span>
<DropdownLabelsIntl items={exportFilterItems} bind:selected={exportFilterMode} kind="regular" size="large" />
</div>
</Card>
+4 -2
View File
@@ -25,7 +25,8 @@ export async function exportToWorkspace (
selectedDocs: Doc[],
targetWorkspace: string | undefined,
relations: RelationDefinition[] | undefined,
skipDeletedObsolete?: boolean
skipDeletedObsolete?: boolean,
exportOnlyEffective?: boolean
): Promise<void> {
const lang = getCurrentLanguage()
@@ -67,7 +68,8 @@ export async function exportToWorkspace (
_class,
relations,
fieldMappers,
skipDeletedObsolete
skipDeletedObsolete,
exportOnlyEffective
}
body.query =
+33 -2
View File
@@ -13,19 +13,50 @@
// limitations under the License.
//
import { type Resources } from '@hcengineering/platform'
import type { Client, Doc, Ref } from '@hcengineering/core'
import exportPlugin, { type ExportResultRecord } from '@hcengineering/export'
import { type Resources, translate } from '@hcengineering/platform'
import { themeStore } from '@hcengineering/ui'
import { get } from 'svelte/store'
import ExportButton from './components/ExportButton.svelte'
import ExportSettings from './components/ExportSettings.svelte'
import ExportToWorkspaceModal from './components/ExportToWorkspaceModal.svelte'
import ExportResultPanel from './components/ExportResultPanel.svelte'
export { default as ExportButton } from './components/ExportButton.svelte'
export { default as ExportSettings } from './components/ExportSettings.svelte'
export { default as ExportToWorkspaceModal } from './components/ExportToWorkspaceModal.svelte'
export { default as ExportResultPanel } from './components/ExportResultPanel.svelte'
export async function getExportResultTitle (_client: Client, _ref: Ref<Doc>, doc?: Doc): Promise<string> {
const record = doc as ExportResultRecord | undefined
if (record === undefined) return ''
if (record.title !== undefined && record.title !== '') return record.title
const lang = get(themeStore).language
const createdOn = record.createdOn ?? Date.now()
const dateStr = new Date(createdOn).toLocaleDateString(lang, {
day: '2-digit',
month: '2-digit',
year: 'numeric'
})
return await translate(
exportPlugin.string.ExportResultRecordTitle,
{
workspace: record.sourceWorkspace,
date: dateStr
},
lang
)
}
export default async (): Promise<Resources> => ({
component: {
ExportButton,
ExportSettings,
ExportToWorkspaceModal
ExportToWorkspaceModal,
ExportResultPanel
},
function: {
ExportResultTitleProvider: getExportResultTitle
}
})
+6 -1
View File
@@ -51,6 +51,11 @@ export default mergeIds(exportId, exportPlugin, {
SelectSpace: '' as IntlString,
NoSelectedDocuments: '' as IntlString,
RequestPermissionToImport: '' as IntlString,
SkipDeletedObsolete: '' as IntlString
SkipDeletedObsolete: '' as IntlString,
ExportOnlyEffective: '' as IntlString,
ExportFilterMode: '' as IntlString,
ExportFilterEffectiveOnly: '' as IntlString,
ExportFilterSkipArchivedObsolete: '' as IntlString,
ExportFilterAll: '' as IntlString
}
})
+2 -1
View File
@@ -40,6 +40,7 @@
"dependencies": {
"@hcengineering/platform": "workspace:^0.7.19",
"@hcengineering/core": "workspace:^0.7.24",
"@hcengineering/ui": "workspace:^0.7.0"
"@hcengineering/ui": "workspace:^0.7.0",
"@hcengineering/notification": "workspace:^0.7.0"
}
}
+27 -3
View File
@@ -13,29 +13,53 @@
// limitations under the License.
//
import { type IntlString, type Metadata, type Plugin, plugin, type Asset } from '@hcengineering/platform'
import type { Class, Client, Doc, Ref } from '@hcengineering/core'
import { type IntlString, type Metadata, type Plugin, plugin, type Resource, type Asset } from '@hcengineering/platform'
import { type AnyComponent } from '@hcengineering/ui/src/types'
import type { NotificationGroup, NotificationType } from '@hcengineering/notification'
import type { ExportResultRecord } from './types'
export const exportId = 'export' as Plugin
export const exportPlugin = plugin(exportId, {
ids: {
ImportNotificationGroup: '' as Ref<NotificationGroup>,
ImportedDocumentsNotification: '' as Ref<NotificationType>
},
class: {
ExportResultRecord: '' as Ref<Class<ExportResultRecord>>
},
string: {
Export: '' as IntlString,
ExportCompleted: '' as IntlString,
ExportFailed: '' as IntlString,
ExportToWorkspace: '' as IntlString,
TargetWorkspace: '' as IntlString
TargetWorkspace: '' as IntlString,
ImportCompleted: '' as IntlString,
ImportToWorkspaceNotificationMessage: '' as IntlString,
SourceWorkspace: '' as IntlString,
ExportedCount: '' as IntlString,
ExportedDocumentIds: '' as IntlString,
DocumentsImportedFromWorkspace: '' as IntlString,
ExportedDocumentClass: '' as IntlString,
Import: '' as IntlString,
ImportedDocuments: '' as IntlString,
ExportResultRecordTitle: '' as IntlString
},
component: {
ExportButton: '' as AnyComponent,
ExportSettings: '' as AnyComponent,
ExportToWorkspaceModal: '' as AnyComponent
ExportToWorkspaceModal: '' as AnyComponent,
ExportResultPanel: '' as AnyComponent
},
icon: {
Export: '' as Asset
},
metadata: {
ExportUrl: '' as Metadata<string>
},
function: {
ExportResultTitleProvider: '' as Resource<(client: Client, ref: Ref<Doc>, doc?: Doc) => Promise<string>>
}
})
+20
View File
@@ -14,6 +14,26 @@
//
import type { Class, Doc, Ref } from '@hcengineering/core'
/**
* @public
*/
export interface ExportResultDoc {
docId: Ref<Doc>
name: string
}
/**
* @public
*/
export interface ExportResultRecord extends Doc {
sourceWorkspace: string
targetWorkspace: string
exportedCount: number
exportedDocumentIds: Ref<Doc>[]
objectClass: Ref<Class<Doc>>
title?: string
}
export enum OperationType {
IDENTITY = 'identity',
GROUP_BY = 'group_by'
+12
View File
@@ -34,3 +34,15 @@ export function shouldSkipDocument (doc: Doc): boolean {
}
return false
}
/**
* Checks if a document has effective status (only such docs should be exported when "export only effective" is on).
* Returns true if the document has no state property or state === 'effective'.
* @param doc - The document to check
* @returns true if the document is effective or has no state, false otherwise
*/
export function isEffectiveDocument (doc: Doc): boolean {
if (!('state' in doc)) return true
const docWithState = doc as Doc & { state?: string }
return typeof docWithState.state === 'string' && docWithState.state === 'effective'
}
+5 -2
View File
@@ -1666,8 +1666,11 @@ export async function getLoginInfoByToken (
}
accountUuid = sub ?? account
} catch (err: any) {
Analytics.handleError(err)
ctx.error('Invalid token', { token, errMsg: err.message })
if (token !== undefined) {
// do not spam errors as this is expected when we issue request with no token
Analytics.handleError(err)
ctx.error('Invalid token', { token, errMsg: err.message })
}
switch (err.message) {
case 'Token not yet active': {
const { nbf } = decodeToken(token, false)
+58 -74
View File
@@ -13,109 +13,93 @@
//
import { getClient } from '@hcengineering/account-client'
import {
import core, {
AccountRole,
concatLink,
Doc,
type AccountUuid,
type Class,
type Doc,
generateId,
MeasureContext,
Ref,
type Ref,
systemAccountUuid,
type TxOperations,
WorkspaceIds,
WorkspaceUuid
} from '@hcengineering/core'
import exportPlugin from '@hcengineering/export'
import notification from '@hcengineering/notification'
import { generateToken } from '@hcengineering/server-token'
import envConfig from './config'
export async function sendExportCompletionEmail (
export async function sendExportCompletionNotification (
ctx: MeasureContext,
targetTxOps: TxOperations,
targetWorkspace: WorkspaceUuid,
targetWsIds: WorkspaceIds,
exportedDocuments: Array<{ docId: Ref<Doc>, name: string }>,
sourceWsIds: WorkspaceIds
sourceWsIds: WorkspaceIds,
objectClass: Ref<Class<Doc>>
): Promise<void> {
try {
const mailURL = envConfig.MailURL
if (mailURL == null || typeof mailURL !== 'string' || mailURL === '') {
ctx.warn('Mail service URL not configured, skipping email notification')
return
}
// Get workspace members to find owners
const count = exportedDocuments.length
const resultId = generateId<Doc>()
await targetTxOps.createDoc(
exportPlugin.class.ExportResultRecord,
core.space.Space,
{
sourceWorkspace: sourceWsIds.url,
targetWorkspace: targetWsIds.url,
exportedCount: count,
exportedDocumentIds: exportedDocuments.map((d) => d.docId),
objectClass
},
resultId
)
const targetWsToken = generateToken(systemAccountUuid, targetWorkspace, { service: 'export' })
const targetAccountClient = getClient(envConfig.AccountsUrl, targetWsToken)
const members = await targetAccountClient.getWorkspaceMembers()
// Find workspace owners
const owners = members.filter((m: any) => m.role === AccountRole.Owner)
const owners = members.filter((m: { role: string }) => m.role === AccountRole.Owner) as Array<{
person: AccountUuid
}>
if (owners.length === 0) {
ctx.warn('No workspace owners found for email notification', { targetWorkspace })
ctx.warn('No workspace owners found for export notification', { targetWorkspace })
return
}
// Get email addresses for owners
const ownerEmails: string[] = []
for (const owner of owners) {
try {
const personInfo = await targetAccountClient.getPersonInfo(owner.person)
const emailSocialId = personInfo.socialIds.find(
(sid: any) => (sid.type === 'EMAIL' || sid.type === 'GOOGLE') && sid.verifiedOn > 0 && sid.isDeleted !== true
)
if (emailSocialId?.value != null && emailSocialId.value !== '') {
ownerEmails.push(emailSocialId.value)
}
} catch (err) {
ctx.warn('Failed to get email for workspace owner', { owner: owner.person, error: err })
}
}
if (ownerEmails.length === 0) {
ctx.warn('No email addresses found for workspace owners', { targetWorkspace })
return
}
// Build email content
const documentList = exportedDocuments.map((doc) => ` - ${doc.name}`).join('\n')
const subject = `Export completed: ${exportedDocuments.length} document${exportedDocuments.length !== 1 ? 's' : ''} exported to your workspace`
const text = `The following ${exportedDocuments.length} document${exportedDocuments.length !== 1 ? 's have' : ' has'} been successfully exported to your workspace:\n\n${documentList}\n\nSource workspace: ${sourceWsIds.uuid}\nTarget workspace: ${targetWsIds.uuid}`
const html = `<p>The following <strong>${exportedDocuments.length}</strong> document${exportedDocuments.length !== 1 ? 's have' : ' has'} been successfully exported to your workspace:</p><ul>${exportedDocuments.map((doc) => `<li>${escapeHtml(doc.name)}</li>`).join('')}</ul><p>Source workspace: ${sourceWsIds.uuid}<br>Target workspace: ${targetWsIds.uuid}</p>`
// Send email to all owners
const mailAuth = envConfig.MailAuthToken
for (const email of ownerEmails) {
try {
const response = await fetch(concatLink(mailURL, '/send'), {
method: 'post',
keepalive: true,
headers: {
'Content-Type': 'application/json',
...(mailAuth != null ? { Authorization: `Bearer ${mailAuth}` } : {})
},
body: JSON.stringify({
text,
html,
subject,
to: [email]
})
const docNotifyContextId = await targetTxOps.createDoc(notification.class.DocNotifyContext, core.space.Space, {
objectId: resultId,
objectClass: exportPlugin.class.ExportResultRecord,
objectSpace: core.space.Space,
user: owner.person,
isPinned: false,
hidden: false
})
await targetTxOps.createDoc(notification.class.CommonInboxNotification, core.space.Space, {
user: owner.person,
objectId: resultId,
objectClass: exportPlugin.class.ExportResultRecord,
icon: exportPlugin.icon.Export,
message: exportPlugin.string.ImportToWorkspaceNotificationMessage,
props: {
count,
sourceWorkspace: sourceWsIds.uuid
},
isViewed: false,
archived: false,
docNotifyContext: docNotifyContextId,
types: [exportPlugin.ids.ImportedDocumentsNotification]
})
if (!response.ok) {
ctx.error(`Failed to send export completion email: ${response.statusText}`, { email })
} else {
ctx.info('Export completion email sent', { email, documentCount: exportedDocuments.length })
}
} catch (err) {
ctx.error('Could not send export completion email', { err, email })
ctx.error('Failed to create export notification for owner', { owner: owner.person, err })
}
}
} catch (err) {
ctx.error('Failed to send export completion email', { err })
ctx.error('Failed to send export completion notification', { err })
}
}
function escapeHtml (text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
}
+10 -5
View File
@@ -64,7 +64,7 @@ import { getConfig } from '@hcengineering/server-pipeline'
import { buildStorageFromConfig } from '@hcengineering/server-storage'
import { Token, decodeToken, generateToken } from '@hcengineering/server-token'
import archiver from 'archiver'
import { sendExportCompletionEmail } from './notifications'
import { sendExportCompletionNotification } from './notifications'
import cors from 'cors'
import express, { type Express, type NextFunction, type Request, type Response } from 'express'
import { createWriteStream } from 'fs'
@@ -405,7 +405,8 @@ export function createServer (
includeAttachments,
relations: rawRelations,
fieldMappers,
skipDeletedObsolete
skipDeletedObsolete,
exportOnlyEffective
}: {
targetWorkspace: WorkspaceUuid
_class: Ref<Class<Doc>>
@@ -417,6 +418,7 @@ export function createServer (
objectSpace?: Ref<Space>
fieldMappers?: Record<string, Record<string, any>>
skipDeletedObsolete?: boolean
exportOnlyEffective?: boolean
} = req.body
// Validate required parameters
@@ -531,18 +533,21 @@ export function createServer (
includeAttachments: includeAttachments ?? true,
relations,
fieldMappers,
skipDeletedObsolete: skipDeletedObsolete ?? true
skipDeletedObsolete: skipDeletedObsolete ?? true,
exportOnlyEffective: exportOnlyEffective ?? false
}
const exportResult: ExportResult = await exporter.export(options)
if (exportResult.success && exportResult.exportedCount > 0) {
await sendExportCompletionEmail(
await sendExportCompletionNotification(
measureCtx,
targetTxOps,
targetWorkspace,
targetWsIds,
exportResult.exportedDocuments,
wsIds
wsIds,
_class
)
}
@@ -49,6 +49,8 @@ export interface ExportOptions {
fieldMappers?: Record<string, Record<string, any>>
// Whether to skip documents and templates in deleted or obsolete state
skipDeletedObsolete?: boolean
// Whether to export only documents with effective status
exportOnlyEffective?: boolean
}
export interface ExportResult {
@@ -24,7 +24,7 @@ import {
} from '@hcengineering/core'
import contact, { type Employee } from '@hcengineering/contact'
import { type StorageAdapter } from '@hcengineering/server-core'
import { shouldSkipDocument } from '@hcengineering/export'
import { isEffectiveDocument, shouldSkipDocument } from '@hcengineering/export'
import { AttachmentExporter } from './attachment-exporter'
import { DataMapper } from './data-mapper'
import { DocumentExporter } from './document-exporter'
@@ -126,7 +126,8 @@ export class CrossWorkspaceExporter {
mapper,
relations = [],
fieldMappers = {},
skipDeletedObsolete = true
skipDeletedObsolete = true,
exportOnlyEffective = false
} = options
// Store field mappers
@@ -204,8 +205,12 @@ export class CrossWorkspaceExporter {
this.context.info(`Processing batch: ${processedCount + 1}-${processedCount + docs.length}`)
// Filter out archived/deleted/obsolete documents if skipDeletedObsolete is enabled
const docsToProcess = skipDeletedObsolete ? docs.filter((doc) => !shouldSkipDocument(doc)) : docs
// Filter by effective status or skip archived/deleted/obsolete
const docsToProcess = exportOnlyEffective
? docs.filter((doc) => isEffectiveDocument(doc))
: skipDeletedObsolete
? docs.filter((doc) => !shouldSkipDocument(doc))
: docs
// Check for existing documents in bulk if needed
const existingDocsMap = new Map<Ref<Doc>, Doc>()
@@ -239,10 +244,14 @@ export class CrossWorkspaceExporter {
(mappedDoc as any).title ??
hierarchy.getClass(mappedDoc._class)?.label ??
mappedDoc._id
result.exportedDocuments.push({
docId: mappedDoc._id,
name: typeof docName === 'string' ? docName : String(docName)
})
// Use target workspace doc id so the notification panel can resolve docs in the target workspace
const targetDocId = this.state.idMapping.get(mappedDoc._id)
if (targetDocId !== undefined) {
result.exportedDocuments.push({
docId: targetDocId,
name: typeof docName === 'string' ? docName : String(docName)
})
}
} else {
result.skippedCount++
}