Type export/import (#10373)

* Type export/import

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix formatting

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix ID attributes

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

---------

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-01-08 11:15:11 +07:00
committed by GitHub
parent b89613780d
commit 90b93b97c8
26 changed files with 347 additions and 35 deletions
+9 -1
View File
@@ -22,6 +22,8 @@ import {
type CardViewDefaults,
type CreateCardExtension,
DOMAIN_CARD,
type ExportExtension,
type ExportFunc,
type FavoriteCard,
type FavoriteType,
type MasterTag,
@@ -202,6 +204,11 @@ export class TCreateCardExtension extends TMasterTag implements CreateCardExtens
canCreate?: CanCreateCardResource
}
@Model(card.class.ExportExtension, core.class.Doc, DOMAIN_MODEL)
export class TExportExtension extends TDoc implements ExportExtension {
func!: Resource<ExportFunc>
}
export * from './migration'
const listConfig: (BuildModelKey | string)[] = [
@@ -387,7 +394,8 @@ export function createModel (builder: Builder): void {
TCardViewDefaults,
TFavoriteCard,
TFavoriteType,
TCreateCardExtension
TCreateCardExtension,
TExportExtension
)
builder.createDoc(
+4
View File
@@ -606,6 +606,10 @@ export function createModel (builder: Builder): void {
props: {}
})
builder.createDoc(card.class.ExportExtension, core.space.Model, {
func: process.function.ExportProcess
})
builder.createDoc(
card.class.CardSection,
core.space.Model,
@@ -315,7 +315,7 @@
<slot name="item" item={obj} />
{/if}
{#if (allowDeselect && selected) || multiSelect || selected}
<div class="check" class:disabled={readonly}>
<div class="check mr-2" class:disabled={readonly}>
{#if obj._id === selected || selectedElements.has(obj._id)}
{#if loading}
<Spinner size={'small'} />
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "Povolit verzování",
"EnableVersioningConfirm": "Chcete povolit verzování?",
"NewVersionConfirmation": "Chcete vytvořit novou verzi?",
"RelationCopyDescr": "Které vztahy chcete zkopírovat?"
"RelationCopyDescr": "Které vztahy chcete zkopírovat?",
"Import": "Importovat",
"Export": "Exportovat"
}
}
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "Versionsverwaltung aktivieren",
"EnableVersioningConfirm": "Möchten Sie die Versionsverwaltung aktivieren?",
"NewVersionConfirmation": "Möchten Sie eine neue Version erstellen?",
"RelationCopyDescr": "Welche Beziehungen möchten Sie kopieren?"
"RelationCopyDescr": "Welche Beziehungen möchten Sie kopieren?",
"Import": "Importieren",
"Export": "Exportieren"
}
}
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "Enable versioning",
"EnableVersioningConfirm": "Do you want to enable versioning?",
"NewVersionConfirmation": "Do you want to create a new version?",
"RelationCopyDescr": "Which relations do you want to copy?"
"RelationCopyDescr": "Which relations do you want to copy?",
"Import": "Import",
"Export": "Export"
}
}
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "Habilitar control de versiones",
"EnableVersioningConfirm": "¿Desea habilitar el control de versiones?",
"NewVersionConfirmation": "¿Desea crear una nueva versión?",
"RelationCopyDescr": "¿Qué relaciones quieres copiar?"
"RelationCopyDescr": "¿Qué relaciones quieres copiar?",
"Import": "Importar",
"Export": "Exportar"
}
}
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "Activer la gestion des versions",
"EnableVersioningConfirm": "Voulez-vous activer la gestion des versions ?",
"NewVersionConfirmation": "Voulez-vous créer une nouvelle version ?",
"RelationCopyDescr": "Quelles relations souhaitez-vous copier ?"
"RelationCopyDescr": "Quelles relations souhaitez-vous copier ?",
"Import": "Importer",
"Export": "Exporter"
}
}
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "Abilita versionamento",
"EnableVersioningConfirm": "Vuoi abilitare il versionamento?",
"NewVersionConfirmation": "Vuoi creare una nuova versione?",
"RelationCopyDescr": "Quali relazioni vuoi copiare?"
"RelationCopyDescr": "Quali relazioni vuoi copiare?",
"Import": "Importa",
"Export": "Esporta"
}
}
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "バージョン管理を有効にする",
"EnableVersioningConfirm": "バージョン管理を有効にしますか?",
"NewVersionConfirmation": "新しいバージョンを作成しますか?",
"RelationCopyDescr": "どの関連をコピーしますか?"
"RelationCopyDescr": "どの関連をコピーしますか?",
"Import": "インポート",
"Export": "エクスポート"
}
}
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "Ativar controle de versões",
"EnableVersioningConfirm": "Deseja ativar o controle de versões?",
"NewVersionConfirmation": "Deseja criar uma nova versão?",
"RelationCopyDescr": "Quais relações você deseja copiar?"
"RelationCopyDescr": "Quais relações você deseja copiar?",
"Import": "Importar",
"Export": "Exportar"
}
}
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "Включить управление версиями",
"EnableVersioningConfirm": "Вы хотите включить управление версиями?",
"NewVersionConfirmation": "Вы хотите создать новую версию?",
"RelationCopyDescr": "Какие связи вы хотите скопировать?"
"RelationCopyDescr": "Какие связи вы хотите скопировать?",
"Import": "Импортировать",
"Export": "Экспортировать"
}
}
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "Sürümlemeyi etkinleştir",
"EnableVersioningConfirm": "Sürümlemeyi etkinleştirmek istiyor musunuz?",
"NewVersionConfirmation": "Yeni bir sürüm oluşturmak istiyor musunuz?",
"RelationCopyDescr": "Hangi ilişkileri kopyalamak istiyorsunuz?"
"RelationCopyDescr": "Hangi ilişkileri kopyalamak istiyorsunuz?",
"Import": "İçe aktar",
"Export": "Dışa aktar"
}
}
+3 -1
View File
@@ -68,6 +68,8 @@
"EnableVersioning": "启用版本控制",
"EnableVersioningConfirm": "您要启用版本控制吗?",
"NewVersionConfirmation": "您想创建一个新版本吗?",
"RelationCopyDescr": "您想复制哪些关系?"
"RelationCopyDescr": "您想复制哪些关系?",
"Import": "导入",
"Export": "导出"
}
}
@@ -108,7 +108,7 @@
{noSelect}
inline
component={card.component.EditCard}
shrink={1}
shrink={0}
title={cardObj?.title}
>
{#if shouldShowAvatar}
@@ -136,7 +136,7 @@
{noSelect}
inline
component={card.component.EditCard}
shrink={1}
shrink={0}
title={cardObj?.title}
>
{#if shouldShowAvatar}
@@ -14,13 +14,13 @@
-->
<script lang="ts">
import { Card, MasterTag } from '@hcengineering/card'
import { AnyAttribute, Class, Ref, RefTo } from '@hcengineering/core'
import { Class, Ref } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { IconWithEmoji, createQuery, getClient } from '@hcengineering/presentation'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Button, ButtonKind, ButtonSize, eventToHTMLElement, Label, showPopup } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import card from '../plugin'
import CardPresenter from './CardPresenter.svelte'
import CardsPopup from './CardsPopup.svelte'
export let value: Ref<Card> | undefined
@@ -70,9 +70,6 @@
$: _classRef = doc?._class ?? _class
$: clazz = _classRef !== undefined ? (hierarchy.findClass(_classRef) as MasterTag) : undefined
$: icon = clazz?.icon === view.ids.IconWithEmoji ? IconWithEmoji : clazz?.icon
$: iconProps = clazz?.icon === view.ids.IconWithEmoji ? { icon: clazz?.color } : {}
</script>
<Button
@@ -81,15 +78,13 @@
{focusIndex}
{width}
{size}
{icon}
{iconProps}
{kind}
disabled={readonly}
on:click={handleOpen}
>
<div slot="content" class="overflow-label">
{#if doc}
{doc.title}
<CardPresenter value={doc} type={'text'} />
{:else}
<Label {label} />
{/if}
@@ -32,7 +32,7 @@
let versions: Card[] = []
const query = createQuery()
$: if (enabled) {
$: if (enabled === true) {
query.query(
card.class.Card,
{
@@ -61,7 +61,7 @@
{readonly}
>
<svelte:fragment slot="item" let:item={it}>
<CardPresenter value={it} disabled />
<CardPresenter value={it} disabled showVersion={false} type={'text'} />
</svelte:fragment>
<svelte:fragment slot="category" let:item={it}>
@@ -14,9 +14,10 @@
-->
<script lang="ts">
import { MasterTag } from '@hcengineering/card'
import core from '@hcengineering/core'
import { TypeNumber } from '@hcengineering/model'
import { getEmbeddedLabel, translateCB } from '@hcengineering/platform'
import { getClient, IconWithEmoji, MessageBox } from '@hcengineering/presentation'
import { getClient, IconDownload, IconWithEmoji, MessageBox } from '@hcengineering/presentation'
import setting from '@hcengineering/setting'
import {
ButtonIcon,
@@ -33,9 +34,9 @@
} from '@hcengineering/ui'
import view from '@hcengineering/view'
import { ColorsPopup, IconPicker } from '@hcengineering/view-resources'
import { exportModule } from '../../exporter'
import card from '../../plugin'
import { deleteMasterTag } from '../../utils'
import core from '@hcengineering/core'
export let masterTag: MasterTag
@@ -99,6 +100,18 @@
)
}
async function handleExport (): Promise<void> {
const str = await exportModule(masterTag._id)
const blob = new Blob([str], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${name}.json`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
function getTagStyle (color: ColorDefinition): string {
return `
background: ${color.color + '33'};
@@ -130,6 +143,7 @@
})
}
let versioningEnabled = h.classHierarchyMixin(masterTag._id, core.mixin.VersionableClass)?.enabled
$: versioningEnabled = h.classHierarchyMixin(masterTag._id, core.mixin.VersionableClass)?.enabled
</script>
@@ -165,12 +179,19 @@
on:click={showColorPopup}
/>
</div>
<ButtonIcon
icon={IconDownload}
size={'large'}
tooltip={{ label: card.string.Export }}
kind={'tertiary'}
on:click={handleExport}
/>
{#if isEditable}
<ButtonIcon icon={IconDelete} size={'large'} kind={'tertiary'} on:click={handleDelete} />
{/if}
</div>
{#if !h.isMixin(masterTag._id)}
<div>
<div class="mx-2">
<ToggleWithLabel
label={card.string.Versioning}
on={versioningEnabled}
@@ -13,10 +13,17 @@
// limitations under the License.
-->
<script lang="ts">
import { ButtonIcon, getCurrentResolvedLocation, IconAdd, navigate, showPopup } from '@hcengineering/ui'
import CreateTag from '../CreateTag.svelte'
import card from '../../plugin'
import { clearSettingsStore } from '@hcengineering/setting-resources'
import {
ButtonIcon,
getCurrentResolvedLocation,
IconAdd,
IconAttachment,
navigate,
showPopup
} from '@hcengineering/ui'
import card from '../../plugin'
import CreateTag from '../CreateTag.svelte'
function handleAdd (): void {
showPopup(
@@ -38,6 +45,39 @@
}
)
}
async function handleImprot (): Promise<void> {
const { importModule } = await import('../../exporter')
const input = document.createElement('input')
input.type = 'file'
input.accept = 'application/json'
input.onchange = async (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files && target.files.length > 0) {
const file = target.files[0]
const reader = new FileReader()
reader.onload = async (event: ProgressEvent<FileReader>) => {
if (event.target?.result !== undefined && typeof event.target.result === 'string') {
try {
await importModule(event.target.result)
} catch (err: any) {
console.error('Failed to import module:', err)
}
}
}
reader.readAsText(file)
}
}
input.click()
}
</script>
<ButtonIcon
id={'new-master-tag'}
icon={IconAttachment}
tooltip={{ label: card.string.Import }}
kind={'tertiary'}
size={'extra-small'}
on:click={handleImprot}
/>
<ButtonIcon id={'new-master-tag'} icon={IconAdd} kind={'tertiary'} size={'extra-small'} on:click={handleAdd} />
+185
View File
@@ -0,0 +1,185 @@
import core, {
type AnyAttribute,
type Class,
type Doc,
type EnumOf,
type ModelDb,
type Hierarchy,
type Ref,
type RefTo,
type ArrOf,
type Data,
type AttachedDoc,
type AttachedData,
type TypeIdentifier
} from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import view from '@hcengineering/view'
import card from './plugin'
import { getResource } from '@hcengineering/platform'
export async function importModule (json: string): Promise<void> {
try {
const data = JSON.parse(json) as Doc[]
const client = getClient()
const m = client.getModel()
const h = client.getHierarchy()
const apply = client.apply('Import')
for (const elem of data) {
if (m.findObject(elem._id) !== undefined) continue
if (h.isDerived(elem._class, core.class.AttachedDoc)) {
const attachedDoc = elem as AttachedDoc
await apply.addCollection(
attachedDoc._class,
attachedDoc.space,
attachedDoc.attachedTo,
attachedDoc.attachedToClass,
attachedDoc.collection,
stripAttached(attachedDoc),
attachedDoc._id
)
} else {
await apply.createDoc(elem._class, elem.space, stripData(elem), elem._id)
}
}
await apply.commit(true)
} catch (e) {
console.error('Failed to import process module:', e)
}
}
function stripAttached (doc: AttachedDoc): AttachedData<AttachedDoc> {
const { collection, attachedTo, attachedToClass, ...rest } = strip(doc)
return rest
}
function stripData<T extends Doc> (doc: T): Data<T> {
const { _id, _class, space, ...rest } = strip(doc)
return rest as Data<T>
}
export async function exportModule (_id: Ref<Class<Doc>>): Promise<string> {
const processed = new Set<Ref<Doc>>()
const res = await exportType(_id, processed)
const clear = res.map(strip)
const str = JSON.stringify(clear)
return str
}
function strip<T extends Doc> (doc: T): Omit<T, 'modifiedBy' | 'modifiedOn' | 'createdBy' | 'createdOn'> {
const { modifiedBy, modifiedOn, createdBy, createdOn, ...rest } = doc
return rest
}
async function exportAttribute (
attr: AnyAttribute,
m: ModelDb,
h: Hierarchy
): Promise<{
docs: Doc[]
required: Array<Ref<Class<Doc>>>
}> {
const client = getClient()
const docs: Doc[] = []
const required: Array<Ref<Class<Doc>>> = []
docs.push(attr)
docs.push(...m.findAllSync(core.class.AttributePermission, { attribute: attr._id }))
if (attr.type._class === core.class.TypeIdentifier) {
const of = (attr.type as TypeIdentifier).of
const seq = await client.findOne(core.class.Sequence, { _id: of })
if (seq !== undefined) {
seq.sequence = 0
docs.push(seq)
}
}
if (attr.type._class === core.class.EnumOf) {
const of = (attr.type as EnumOf).of
const val = m.findObject(of)
if (val !== undefined) docs.push(val)
}
if (attr.type._class === core.class.RefTo) {
const to = (attr.type as RefTo<Doc>).to
if (h.isDerived(to, card.class.Card)) {
required.push(to)
}
}
if (attr.type._class === core.class.ArrOf) {
const of = (attr.type as ArrOf<Doc>).of
if (of._class === core.class.RefTo) {
const to = (of as RefTo<Doc>).to
if (h.isDerived(to, card.class.Card)) {
required.push(to)
}
}
if (of._class === core.class.EnumOf) {
const val = m.findObject((of as EnumOf).of)
if (val !== undefined) docs.push(val)
}
}
return { docs, required }
}
async function exportType (_id: Ref<Class<Doc>>, processed: Set<Ref<Doc>>): Promise<Doc[]> {
if (processed.has(_id)) return []
const res: Doc[] = []
const required: Array<Ref<Class<Doc>>> = []
const client = getClient()
const m = client.getModel()
const h = client.getHierarchy()
const type = m.findObject(_id)
processed.add(_id)
if (type === undefined) return res
res.push(type)
res.push(...m.findAllSync(core.class.ClassPermission, { targetClass: _id }))
const attrs = h.getOwnAttributes(_id)
for (const attr of attrs) {
const at = await exportAttribute(attr[1], m, h)
res.push(...at.docs)
required.push(...at.required)
}
const descendants = h.getDescendants(_id)
for (const desc of descendants) {
if (h.getClass(desc).extends !== _id) continue
required.push(desc)
}
res.push(...m.findAllSync(card.class.Role, { types: _id }))
res.push(...m.findAllSync(view.class.Viewlet, { attachedTo: _id }))
const assocA = m.findAllSync(core.class.Association, { classA: _id })
for (const assoc of assocA) {
res.push(assoc)
required.push(assoc.classB)
}
const assocB = m.findAllSync(core.class.Association, { classB: _id })
for (const assoc of assocB) {
res.push(assoc)
required.push(assoc.classA)
}
const extensions = m.findAllSync(card.class.ExportExtension, {})
for (const ext of extensions) {
const f = await getResource(ext.func)
const extRes = f(_id)
res.push(...extRes.docs)
required.push(...extRes.required)
}
const parent = h.getClass(_id).extends
if (parent !== undefined && h.isDerived(parent, card.class.Card) && parent !== card.class.Card) {
if (!processed.has(parent)) {
required.push(parent)
}
}
for (const req of required) {
res.push(...(await exportType(req, processed)))
}
return res
}
+2
View File
@@ -128,6 +128,8 @@ export default mergeIds(cardId, card, {
Post: '' as IntlString,
ShowLess: '' as IntlString,
Duplicate: '' as IntlString,
Export: '' as IntlString,
Import: '' as IntlString,
NewVersion: '' as IntlString,
Versioning: '' as IntlString,
EnableVersioning: '' as IntlString,
+11 -1
View File
@@ -119,6 +119,15 @@ export interface PermissionObjectClass extends Doc {
export type CanCreateCardFn = (space: Ref<Space>, data: Partial<Data<Card>>) => Promise<boolean | Ref<Card>>
export type CanCreateCardResource = Resource<CanCreateCardFn>
export interface ExportExtension extends Doc {
func: Resource<ExportFunc>
}
export type ExportFunc = (id: Ref<MasterTag>) => {
docs: Doc[]
required: Ref<Class<Doc>>[]
}
/**
* @public
*/
@@ -140,7 +149,8 @@ const cardPlugin = plugin(cardId, {
CardSection: '' as Ref<Class<CardSection>>,
FavoriteCard: '' as Ref<Class<FavoriteCard>>,
FavoriteType: '' as Ref<Class<FavoriteType>>,
PermissionObjectClass: '' as Ref<Class<PermissionObjectClass>>
PermissionObjectClass: '' as Ref<Class<PermissionObjectClass>>,
ExportExtension: '' as Ref<Class<ExportExtension>>
},
mixin: {
CardViewDefaults: '' as Ref<Mixin<CardViewDefaults>>,
+22
View File
@@ -0,0 +1,22 @@
import { type MasterTag } from '@hcengineering/card'
import { type Class, type Doc, type Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import process from './plugin'
export function exportProcess (_id: Ref<MasterTag>): {
docs: Doc[]
required: Array<Ref<Class<Doc>>>
} {
const docs: Doc[] = []
const client = getClient()
const m = client.getModel()
const processes = m.findAllSync(process.class.Process, { masterTag: _id })
for (const proc of processes) {
docs.push(proc)
docs.push(...m.findAllSync(process.class.State, { process: proc._id }))
docs.push(...m.findAllSync(process.class.Transition, { process: proc._id }))
}
return { docs, required: [] }
}
+2
View File
@@ -89,6 +89,7 @@ import {
eventCheck
} from './utils'
import FieldChangesEditor from './components/settings/FieldChangesEditor.svelte'
import { exportProcess } from './exporter'
export * from './query'
@@ -178,6 +179,7 @@ export default async (): Promise<Resources> => ({
OnEventCheck: eventCheck
},
function: {
ExportProcess: exportProcess,
ShowDoneQuery: showDoneQuery,
// eslint-disable-next-line @typescript-eslint/unbound-method
CreateMiddleware: ProcessMiddleware.create
+3 -2
View File
@@ -11,7 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
import { Card, MasterTag, Tag } from '@hcengineering/card'
import { Card, ExportFunc, MasterTag, Tag } from '@hcengineering/card'
import { Association, Class, Client, Doc, DocumentUpdate, ObjQueryType, Rank, Ref, Tx, Type } from '@hcengineering/core'
import { Asset, IntlString, Plugin, plugin, Resource } from '@hcengineering/platform'
import { ToDo } from '@hcengineering/time'
@@ -339,6 +339,7 @@ export default plugin(processId, {
ExecutionEmployeeInitiator: '' as Ref<ProcessFunction>,
ExecutionInitiator: '' as Ref<ProcessFunction>,
EmptyArray: '' as Ref<ProcessFunction>,
CurrentDate: '' as Ref<ProcessFunction>
CurrentDate: '' as Ref<ProcessFunction>,
ExportProcess: '' as Resource<ExportFunc>
}
})