Merge with develop

Signed-off-by: Artyom Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2026-07-12 08:29:59 +07:00
124 changed files with 3730 additions and 235 deletions
+2 -1
View File
@@ -113,4 +113,5 @@ dev/tool/history.json
/combined_dependencies
.tmp
ws-tests/docker-compose.override.yml
.cursor/*
.cursor/*
CLAUDE.md
+6
View File
@@ -99,6 +99,12 @@ For detailed information about the platform architecture, services, and their in
- [Docker](https://docs.docker.com/get-docker/)
- [Docker Compose](https://docs.docker.com/compose/install/)
If you use `nvm`, run this after entering the repo to align your shell with the repository Node version:
```bash
nvm use
```
## Verification
To verify the installation, perform the following checks in your terminal:
+112
View File
@@ -10446,6 +10446,9 @@ importers:
'@hcengineering/server-notification':
specifier: workspace:^0.7.0
version: link:../../server-plugins/notification
'@hcengineering/view':
specifier: workspace:^0.7.0
version: link:../../plugins/view
devDependencies:
'@hcengineering/platform-rig':
specifier: workspace:^0.7.21
@@ -40215,6 +40218,115 @@ importers:
specifier: ^5.9.3
version: 5.9.3
../../services/notification/pod-events-processor:
dependencies:
'@hcengineering/account-client':
specifier: workspace:^0.7.25
version: link:../../../foundations/core/packages/account-client
'@hcengineering/analytics':
specifier: workspace:^0.7.19
version: link:../../../foundations/core/packages/analytics
'@hcengineering/analytics-service':
specifier: workspace:^0.7.19
version: link:../../../foundations/core/packages/analytics-service
'@hcengineering/api-client':
specifier: workspace:^0.7.25
version: link:../../../foundations/core/packages/api-client
'@hcengineering/calendar':
specifier: workspace:^0.7.0
version: link:../../../plugins/calendar
'@hcengineering/contact':
specifier: workspace:^0.7.0
version: link:../../../plugins/contact
'@hcengineering/core':
specifier: workspace:^0.7.26
version: link:../../../foundations/core/packages/core
'@hcengineering/kafka':
specifier: workspace:^0.7.18
version: link:../../../foundations/server/packages/kafka
'@hcengineering/notification':
specifier: workspace:^0.7.0
version: link:../../../plugins/notification
'@hcengineering/platform':
specifier: workspace:^0.7.20
version: link:../../../foundations/core/packages/platform
'@hcengineering/server-client':
specifier: workspace:^0.7.16
version: link:../../../foundations/server/packages/client
'@hcengineering/server-core':
specifier: workspace:^0.7.19
version: link:../../../foundations/server/packages/core
'@hcengineering/server-token':
specifier: workspace:^0.7.18
version: link:../../../foundations/core/packages/token
'@hcengineering/text-core':
specifier: workspace:^0.7.19
version: link:../../../foundations/core/packages/text-core
'@hcengineering/time':
specifier: workspace:^0.7.0
version: link:../../../plugins/time
dotenv:
specifier: ^16.4.5
version: 16.6.1
devDependencies:
'@hcengineering/platform-rig':
specifier: workspace:^0.7.21
version: link:../../../foundations/utils/packages/platform-rig
'@tsconfig/node16':
specifier: ^1.0.4
version: 1.0.4
'@types/jest':
specifier: ^29.5.5
version: 29.5.14
'@types/node':
specifier: ^22.18.1
version: 22.19.0
'@typescript-eslint/eslint-plugin':
specifier: ^6.21.0
version: 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
'@typescript-eslint/parser':
specifier: ^6.21.0
version: 6.21.0(eslint@8.57.1)(typescript@5.9.3)
cross-env:
specifier: ~7.0.3
version: 7.0.3
esbuild:
specifier: ^0.25.10
version: 0.25.12
eslint:
specifier: ^8.54.0
version: 8.57.1
eslint-config-standard-with-typescript:
specifier: ^40.0.0
version: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3))(eslint-plugin-import@2.32.0(eslint@8.57.1))(eslint-plugin-n@15.7.0(eslint@8.57.1))(eslint-plugin-promise@6.6.0(eslint@8.57.1))(eslint@8.57.1)(typescript@5.9.3)
eslint-plugin-import:
specifier: ^2.26.0
version: 2.32.0(eslint@8.57.1)
eslint-plugin-n:
specifier: ^15.4.0
version: 15.7.0(eslint@8.57.1)
eslint-plugin-node:
specifier: ^11.1.0
version: 11.1.0(eslint@8.57.1)
eslint-plugin-promise:
specifier: ^6.1.1
version: 6.6.0(eslint@8.57.1)
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3))
prettier:
specifier: ^3.6.2
version: 3.6.2
ts-jest:
specifier: ^29.1.1
version: 29.4.5(@babel/core@7.28.5)(@jest/transform@29.7.0)(@jest/types@30.2.0)(babel-jest@29.7.0(@babel/core@7.28.5))(esbuild@0.25.12)(jest-util@30.2.0)(jest@29.7.0(@types/node@22.19.0)(ts-node@10.9.2(@types/node@22.19.0)(typescript@5.9.3)))(typescript@5.9.3)
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@22.19.0)(typescript@5.9.3)
typescript:
specifier: ^5.9.3
version: 5.9.3
../../services/notification/pod-notification:
dependencies:
'@hcengineering/analytics':
+2 -1
View File
@@ -58,5 +58,6 @@ else
--to @hcengineering/pod-process \
--to @hcengineering/pod-rating \
--to @hcengineering/pod-payment \
--to @hcengineering/pod-worker
--to @hcengineering/pod-worker \
--to @hcengineering/pod-events-processor
fi
+18
View File
@@ -595,6 +595,24 @@ services:
- QUEUE_CONFIG=${QUEUE_CONFIG}
- QUEUE_REGION=cockroach
restart: unless-stopped
events-processor:
image: hardcoreeng/events-processor
extra_hosts:
- 'huly.local:host-gateway'
depends_on:
redpanda:
condition: service_started
account:
condition: service_started
environment:
- SERVICE_ID=events-processor
- LOG_LEVEL=debug
- SECRET=secret
- ACCOUNTS_URL=http://huly.local:3000
- QUEUE_CONFIG=${QUEUE_CONFIG}
- QUEUE_REGION=cockroach
- OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318/v1/traces
restart: unless-stopped
# translate:
# image: hardcoreeng/translate
# extra_hosts:
@@ -16,7 +16,7 @@ export class SplitLogger implements MeasureLogger {
const rootDir = this.opts.root ?? 'logs'
this.logger = winston.createLogger({
level: 'info',
level: process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info',
exitOnError: false
})
const errorPrinter = ({ message, stack, ...rest }: Error): object => ({
@@ -99,6 +99,13 @@ export class SplitLogger implements MeasureLogger {
this.logger.warn({ message, ...obj })
}
debug (message: string, obj?: Record<string, any>): void {
if (this.opts.parent !== undefined) {
this.opts.parent.debug({ message, ...obj })
}
this.logger.debug({ message, ...obj })
}
logOperation (operation: string, time: number, params: ParamsType): void {
this.logger.info(operation, { time, ...params })
}
@@ -22,6 +22,7 @@ describe('telemetry', () => {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
@@ -11,6 +11,7 @@ import {
updateMeasure,
type FullParamsType,
type MeasureLogger,
type MeasureLogLevel,
type Metrics,
type ParamsType,
type WithOptions
@@ -97,7 +98,8 @@ export class OpenTelemetryMetricsContext implements MeasureContext {
readonly logParams?: ParamsType,
readonly otlpLogger?: Logger,
readonly meter?: MetricsContext
readonly meter?: MetricsContext,
readonly logLevel: MeasureLogLevel = 'info'
) {
this.name = name
this.params = params
@@ -133,6 +135,7 @@ export class OpenTelemetryMetricsContext implements MeasureContext {
logger?: MeasureLogger
span?: WithOptions['span'] // By default true
meta?: Record<string, string | number | boolean>
logLevel?: MeasureLogLevel
}
): MeasureContext {
let _span: Span | undefined
@@ -170,7 +173,8 @@ export class OpenTelemetryMetricsContext implements MeasureContext {
this,
this.logParams,
this.otlpLogger,
this.meter
this.meter,
opt?.logLevel ?? this.logLevel
)
result.id = this.id
result.contextData = this.contextData
@@ -309,6 +313,23 @@ export class OpenTelemetryMetricsContext implements MeasureContext {
this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
debug (message: string, args?: Record<string, any>): void {
if (this.logLevel !== 'debug') return
if (this.otlpLogger !== undefined) {
this.otlpLogger.emit({
severityNumber: SeverityNumber.DEBUG,
severityText: 'debug',
context: this.context,
body: message,
attributes: {
'service.name': sdkServiceName,
...(args ?? {})
}
})
}
this.logger.debug(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
end (): void {
this.done()
}
@@ -530,7 +551,8 @@ export function createOpenTelemetryMetricsContext (
): MeasureContext {
if (!initOpenTelemetrySDK(name, version ?? '')) {
console.warn('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is not set, OpenTelemetry metrics will not be sent')
return new MeasureMetricsContext(name, params, fullParams, metrics, logger)
const rootLogLevel: MeasureLogLevel = process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info'
return new MeasureMetricsContext(name, params, fullParams, metrics, logger, undefined, undefined, rootLogLevel)
}
// Traces
@@ -542,6 +564,8 @@ export function createOpenTelemetryMetricsContext (
const meter = otelMetrics.getMeter(name, version)
const rootLogLevel: MeasureLogLevel = process.env.LOG_LEVEL === 'debug' ? 'debug' : 'info'
const ctx = new OpenTelemetryMetricsContext(
name,
tracer,
@@ -554,7 +578,8 @@ export function createOpenTelemetryMetricsContext (
undefined,
undefined,
otlpLogger,
new MetricsContext(meter)
new MetricsContext(meter),
rootLogLevel
)
return ctx
}
@@ -21,6 +21,7 @@ describe('context', () => {
expect(typeof logger.info).toBe('function')
expect(typeof logger.error).toBe('function')
expect(typeof logger.warn).toBe('function')
expect(typeof logger.debug).toBe('function')
expect(typeof logger.close).toBe('function')
})
@@ -54,6 +55,16 @@ describe('context', () => {
consoleSpy.mockRestore()
})
it('should log debug messages', () => {
const consoleSpy = jest.spyOn(console, 'debug').mockImplementation()
const logger = consoleLogger({ service: 'test' })
logger.debug('Debug message', { detail: 'x' })
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('should handle errors in params', () => {
const consoleSpy = jest.spyOn(console, 'error').mockImplementation()
const logger = consoleLogger({})
@@ -145,6 +156,7 @@ describe('context', () => {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
@@ -163,6 +175,7 @@ describe('context', () => {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
@@ -181,6 +194,7 @@ describe('context', () => {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
@@ -194,6 +208,47 @@ describe('context', () => {
)
})
it('should not log debug when log level is info', () => {
const mockLogger: MeasureLogger = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
const ctx = new MeasureMetricsContext('test', { op: 'test' }, {}, newMetrics(), mockLogger)
ctx.debug('Skip', { k: 1 })
expect(mockLogger.debug).not.toHaveBeenCalled()
})
it('should log debug when log level is debug', () => {
const mockLogger: MeasureLogger = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
const ctx = new MeasureMetricsContext(
'test',
{ op: 'test' },
{},
newMetrics(),
mockLogger,
undefined,
undefined,
'debug'
)
ctx.debug('D', { k: 1 })
expect(mockLogger.debug).toHaveBeenCalledWith('D', expect.objectContaining({ k: 1, op: 'test' }))
})
it('should get params', () => {
const ctx = new MeasureMetricsContext('test', { op: 'test', method: 'GET' }, {}, newMetrics(), logger)
const params = ctx.getParams()
@@ -440,6 +495,7 @@ describe('context', () => {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
close: jest.fn(async () => {}),
logOperation: jest.fn()
}
@@ -6,6 +6,7 @@ import {
type FullParamsType,
type MeasureContext,
type MeasureLogger,
type MeasureLogLevel,
type Metrics,
type ParamsType,
type OperationLog,
@@ -42,6 +43,14 @@ export const consoleLogger = (logParams: Record<string, any>): MeasureLogger =>
warn: (msg, args) => {
console.warn(msg, ...Object.entries(args ?? {}).map((it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}`))
},
debug: (msg, args) => {
console.debug(
msg,
...Object.entries({ ...(args ?? {}), ...(logParams ?? {}) }).map(
(it) => `${it[0]}=${JSON.stringify(replacer(it[1]))}`
)
)
},
close: async () => {},
logOperation: (operation, time, params) => {}
})
@@ -62,6 +71,8 @@ export class MeasureMetricsContext implements MeasureContext {
metrics: Metrics
id?: string
private readonly logLevel: MeasureLogLevel
st = platformNow()
contextData: object = {}
private done (value?: number, override?: boolean): void {
@@ -75,12 +86,14 @@ export class MeasureMetricsContext implements MeasureContext {
metrics: Metrics = newMetrics(),
logger?: MeasureLogger,
readonly parent?: MeasureContext,
readonly logParams?: ParamsType
readonly logParams?: ParamsType,
logLevel: MeasureLogLevel = 'info'
) {
this.name = name
this.params = params
this.fullParams = fullParams
this.metrics = metrics
this.logLevel = logLevel
this.metrics.namedParams = this.metrics.namedParams ?? {}
for (const [k, v] of Object.entries(params)) {
if (this.metrics.namedParams[k] !== v) {
@@ -94,7 +107,16 @@ export class MeasureMetricsContext implements MeasureContext {
}
measure (name: string, value: number, override?: boolean): void {
const c = new MeasureMetricsContext('#' + name, {}, {}, childMetrics(this.metrics, ['#' + name]), this.logger, this)
const c = new MeasureMetricsContext(
'#' + name,
{},
{},
childMetrics(this.metrics, ['#' + name]),
this.logger,
this,
undefined,
this.logLevel
)
c.contextData = this.contextData
c.done(value, override)
}
@@ -106,6 +128,8 @@ export class MeasureMetricsContext implements MeasureContext {
fullParams?: FullParamsType
logger?: MeasureLogger
span?: WithOptions['span'] // By default true
meta?: Record<string, string | number | boolean>
logLevel?: MeasureLogLevel
}
): MeasureContext {
const result = new MeasureMetricsContext(
@@ -115,7 +139,8 @@ export class MeasureMetricsContext implements MeasureContext {
childMetrics(this.metrics, [name]),
opt?.logger ?? this.logger,
this,
this.logParams
this.logParams,
opt?.logLevel ?? this.logLevel
)
result.id = this.id
result.contextData = this.contextData
@@ -187,6 +212,11 @@ export class MeasureMetricsContext implements MeasureContext {
this.logger.warn(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
debug (message: string, args?: Record<string, any>): void {
if (this.logLevel !== 'debug') return
this.logger.debug(message, { ...this.params, ...args, ...(this.logParams ?? {}) })
}
end (): void {
this.done()
}
@@ -202,8 +232,11 @@ export class NoMetricsContext implements MeasureContext {
contextData: object = {}
constructor (logger?: MeasureLogger) {
private readonly logLevel: MeasureLogLevel
constructor (logger?: MeasureLogger, logLevel: MeasureLogLevel = 'info') {
this.logger = logger ?? consoleLogger({})
this.logLevel = logLevel
}
measure (name: string, value: number, override?: boolean): void {}
@@ -211,10 +244,15 @@ export class NoMetricsContext implements MeasureContext {
newChild (
name: string,
params: ParamsType,
fullParams?: FullParamsType | (() => FullParamsType),
logger?: MeasureLogger
opt?: {
fullParams?: FullParamsType | (() => FullParamsType)
logger?: MeasureLogger
span?: WithOptions['span']
meta?: Record<string, string | number | boolean>
logLevel?: MeasureLogLevel
}
): MeasureContext {
const result = new NoMetricsContext(logger ?? this.logger)
const result = new NoMetricsContext(opt?.logger ?? this.logger, opt?.logLevel ?? this.logLevel)
result.id = this.id
result.contextData = this.contextData
return result
@@ -226,7 +264,7 @@ export class NoMetricsContext implements MeasureContext {
op: (ctx: MeasureContext) => T | Promise<T>,
fullParams?: ParamsType | (() => FullParamsType)
): Promise<T> {
const r = op(this.newChild(name, params, fullParams, this.logger))
const r = op(this.newChild(name, params, { fullParams, logger: this.logger }))
return r instanceof Promise ? r : Promise.resolve(r)
}
@@ -240,7 +278,7 @@ export class NoMetricsContext implements MeasureContext {
op: (ctx: MeasureContext) => T,
fullParams?: ParamsType | (() => FullParamsType)
): T {
const c = this.newChild(name, params, fullParams, this.logger)
const c = this.newChild(name, params, { fullParams, logger: this.logger })
return op(c)
}
@@ -250,7 +288,7 @@ export class NoMetricsContext implements MeasureContext {
op: (ctx: MeasureContext) => T | Promise<T>,
fullParams?: ParamsType
): Promise<T> {
const r = op(this.newChild(name, params, fullParams, this.logger))
const r = op(this.newChild(name, params, { fullParams, logger: this.logger }))
return r instanceof Promise ? r : Promise.resolve(r)
}
@@ -266,6 +304,11 @@ export class NoMetricsContext implements MeasureContext {
this.logger.warn(message, { ...args })
}
debug (message: string, args?: Record<string, any>): void {
if (this.logLevel !== 'debug') return
this.logger.debug(message, { ...args })
}
end (): void {}
getParams (): ParamsType {
@@ -49,6 +49,12 @@ export interface Metrics extends MetricsData {
opLog?: Record<string, OperationLog>
}
/**
* Root log verbosity for {@link MeasureContext.debug} (child contexts inherit unless overridden in {@link MeasureContext.newChild}).
* @public
*/
export type MeasureLogLevel = 'info' | 'debug'
/**
* @public
*/
@@ -58,6 +64,8 @@ export interface MeasureLogger {
warn: (message: string, obj?: Record<string, any>) => void
debug: (message: string, obj?: Record<string, any>) => void
logOperation: (operation: string, time: number, params: ParamsType) => void
childLogger?: (name: string, params: Record<string, any>) => MeasureLogger
@@ -94,6 +102,7 @@ export interface MeasureContext<Q = any> {
logger?: MeasureLogger
span?: WithOptions['span'] // By default true
meta?: Record<string, string | number | boolean>
logLevel?: MeasureLogLevel
}
) => MeasureContext
@@ -128,6 +137,7 @@ export interface MeasureContext<Q = any> {
error: (message: string, obj?: Record<string, any>) => void
info: (message: string, obj?: Record<string, any>) => void
warn: (message: string, obj?: Record<string, any>) => void
debug: (message: string, obj?: Record<string, any>) => void // No-op unless this context was created with log level `debug`.
// Mark current context as complete
// If no value is passed, time difference will be used.
@@ -57,14 +57,24 @@ describe('DatalakeStorage', () => {
expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}`)
})
it('should handle special characters in parameters', () => {
it('should encode special characters in parameters', () => {
const workspace = 'test workspace'
const file = 'file 123'
const filename = 'my document.pdf'
const url = storage.getFileUrl(workspace, file, filename)
expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}/${filename}`)
expect(url).toBe(`${baseUrl}/blob/test%20workspace/file%20123/my%20document.pdf`)
})
it('should encode slash in filename as a single path segment', () => {
const workspace = 'test-workspace'
const file = 'file-123'
const filename = 'folder/document.pdf'
const url = storage.getFileUrl(workspace, file, filename)
expect(url).toBe(`${baseUrl}/blob/${workspace}/${file}/folder%2Fdocument.pdf`)
})
it('should handle base URL with trailing slash', () => {
@@ -55,14 +55,24 @@ describe('FrontStorage', () => {
expect(url).toBe(`${baseUrl}/${workspace}/${filename}?file=${file}&workspace=${workspace}`)
})
it('should handle special characters in workspace and file names', () => {
it('should encode special characters in workspace, file, and filename', () => {
const workspace = 'test workspace'
const file = 'file 123'
const filename = 'my document.pdf'
const url = storage.getFileUrl(workspace, file, filename)
expect(url).toBe(`${baseUrl}/${workspace}/${filename}?file=${file}&workspace=${workspace}`)
expect(url).toBe(`${baseUrl}/test%20workspace/my%20document.pdf?file=file%20123&workspace=test%20workspace`)
})
it('should encode slash in filename as a single path segment', () => {
const workspace = 'test-workspace'
const file = 'file-123'
const filename = 'folder/document.pdf'
const url = storage.getFileUrl(workspace, file, filename)
expect(url).toBe(`${baseUrl}/${workspace}/folder%2Fdocument.pdf?file=${file}&workspace=${workspace}`)
})
it('should handle base URL with trailing slash', () => {
@@ -16,6 +16,7 @@
import { concatLink } from '@hcengineering/core'
import { FileStorage, FileStorageUploadOptions } from '../types'
import { uploadMultipart, uploadXhr } from '../upload'
import { encodePathSegment } from './utils'
const getPathname = (url: string): string => {
const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost'
@@ -27,7 +28,12 @@ export class DatalakeStorage implements FileStorage {
constructor (private readonly baseUrl: string) {}
getFileUrl (workspace: string, file: string, filename?: string): string {
const path = filename !== undefined ? `/blob/${workspace}/${file}/${filename}` : `/blob/${workspace}/${file}`
const encodedWorkspace = encodePathSegment(workspace)
const encodedFile = encodePathSegment(file)
const path =
filename !== undefined
? `/blob/${encodedWorkspace}/${encodedFile}/${encodePathSegment(filename)}`
: `/blob/${encodedWorkspace}/${encodedFile}`
return concatLink(this.baseUrl, path)
}
@@ -16,6 +16,7 @@
import { concatLink } from '@hcengineering/core'
import { FileStorage, FileStorageUploadOptions } from '../types'
import { uploadXhr } from '../upload'
import { encodePathSegment } from './utils'
const getPathname = (url: string): string => {
const base = window?.location?.href !== undefined ? window.location.href : 'http://localhost'
@@ -27,7 +28,10 @@ export class FrontStorage implements FileStorage {
constructor (private readonly baseUrl: string) {}
getFileUrl (workspace: string, file: string, filename?: string): string {
const path = `/${workspace}/${filename ?? file}?file=${file}&workspace=${workspace}`
const encodedWorkspace = encodePathSegment(workspace)
const encodedFile = encodePathSegment(file)
const encodedFilename = encodePathSegment(filename ?? file)
const path = `/${encodedWorkspace}/${encodedFilename}?file=${encodedFile}&workspace=${encodedWorkspace}`
return concatLink(this.baseUrl, path)
}
@@ -0,0 +1,18 @@
//
// 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.
//
export function encodePathSegment (value: string): string {
return encodeURIComponent(value)
}
@@ -150,6 +150,7 @@ const mockContext: MeasureContext = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
with: jest.fn().mockImplementation((name, params, fn) => fn()),
withSync: jest.fn().mockImplementation((name, params, fn) => fn()),
measure: jest.fn(),
@@ -259,9 +259,14 @@ class PlatformQueueConsumerImpl implements ConsumerHandle {
maxRetryDelay?: number // Maximum retry delay in seconds (default 10)
}
) {
// Long handlers must call ConsumerControl.heartbeat(); these timeouts still help under broker/load jitter (e.g. Redpanda in Docker).
const sessionTimeout = parseInt(process.env.KAFKA_CONSUMER_SESSION_TIMEOUT_MS ?? '90000', 10)
this.cc = this.kafka.consumer({
groupId: `${getKafkaTopicId(this.topic, this.config)}-${groupId}`,
allowAutoTopicCreation: true
allowAutoTopicCreation: true,
sessionTimeout,
rebalanceTimeout: Math.min(sessionTimeout * 2, 300000),
heartbeatInterval: 3000
})
void this.start().catch((err) => {
+16 -7
View File
@@ -279,17 +279,20 @@ export function createModel (builder: Builder): void {
{
hidden: false,
generated: false,
allowedForAuthor: true,
label: calendar.string.Reminder,
group: calendar.ids.CalendarNotificationGroup,
txClasses: [],
// Scheduled reminders are created by the events-processor worker, but provider/type settings still expect a
// tx class list. The notification doc itself is materialized via a direct createDoc, not by a tx trigger.
txClasses: [core.class.TxCreateDoc],
objectClass: calendar.class.Event,
allowedForAuthor: true,
onlyOwn: true,
defaultEnabled: true,
templates: {
textTemplate: 'Reminder: {doc}',
htmlTemplate: 'Reminder: {doc}',
subjectTemplate: 'Reminder: {doc}'
},
defaultEnabled: false
textTemplate: '{body}',
htmlTemplate: '<p>{body}</p><p>{link}</p>',
subjectTemplate: '{title}'
}
},
calendar.ids.ReminderNotification
)
@@ -300,6 +303,12 @@ export function createModel (builder: Builder): void {
enabledTypes: [calendar.ids.ReminderNotification]
})
builder.createDoc(notification.class.NotificationProviderDefaults, core.space.Model, {
provider: notification.providers.PushNotificationProvider,
ignoredTypes: [],
enabledTypes: [calendar.ids.ReminderNotification]
})
builder.createDoc(
activity.class.DocUpdateMessageViewlet,
core.space.Model,
-1
View File
@@ -55,7 +55,6 @@ export default mergeIds(calendarId, calendar, {
string: {
ApplicationLabelCalendar: '' as IntlString,
Event: '' as IntlString,
Reminder: '' as IntlString,
Shift: '' as IntlString,
State: '' as IntlString,
CreatedReminder: '' as IntlString,
+1
View File
@@ -37,6 +37,7 @@
"@hcengineering/core": "workspace:^0.7.26",
"@hcengineering/model": "workspace:^0.7.17",
"@hcengineering/platform": "workspace:^0.7.20",
"@hcengineering/view": "workspace:^0.7.0",
"@hcengineering/card": "workspace:^0.7.0",
"@hcengineering/communication": "workspace:^0.7.0",
"@hcengineering/server-notification": "workspace:^0.7.0",
+11
View File
@@ -21,6 +21,7 @@ import serverCard from '@hcengineering/server-card'
import card from '@hcengineering/card'
import communication from '@hcengineering/communication'
import serverNotification from '@hcengineering/server-notification'
import view from '@hcengineering/view'
export { serverCardId } from '@hcengineering/server-card'
@@ -43,6 +44,16 @@ export function createModel (builder: Builder): void {
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverCard.trigger.OnViewletUpdate,
isAsync: true,
txMatch: {
_class: core.class.TxUpdateDoc,
objectClass: view.class.Viewlet,
'operations.config': { $exists: true }
}
})
builder.createDoc(serverCore.class.Trigger, core.space.Model, {
trigger: serverCard.trigger.OnTagRemove,
txMatch: {
+1 -1
View File
@@ -419,7 +419,7 @@ export function createModel (builder: Builder): void {
component: exportPlugin.component.ExportSettings,
group: 'settings-editor',
feature: 'export',
role: AccountRole.User,
role: AccountRole.Owner,
order: 4800
},
setting.ids.Export
@@ -0,0 +1,49 @@
//
// 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.
//
import { DOMAIN_TASK } from '@hcengineering/model-task'
import tracker from '@hcengineering/tracker'
import { DOMAIN_TRACKER } from '../types'
import { migrateAddStartDate } from '../migration'
describe('migrateAddStartDate', () => {
it('sets startDate=null on every Issue lacking the field (DOMAIN_TASK)', async () => {
const update = jest.fn().mockResolvedValue(undefined)
const client: any = { update }
await migrateAddStartDate(client)
expect(update).toHaveBeenCalledWith(
DOMAIN_TASK,
{ _class: tracker.class.Issue, startDate: { $exists: false } },
{ startDate: null }
)
})
it('sets startDate=null on every Milestone lacking the field (DOMAIN_TRACKER)', async () => {
const update = jest.fn().mockResolvedValue(undefined)
const client: any = { update }
await migrateAddStartDate(client)
expect(update).toHaveBeenCalledWith(
DOMAIN_TRACKER,
{ _class: tracker.class.Milestone, startDate: { $exists: false } },
{ startDate: null }
)
})
it('issues exactly two update calls (one per class)', async () => {
const update = jest.fn().mockResolvedValue(undefined)
const client: any = { update }
await migrateAddStartDate(client)
expect(update).toHaveBeenCalledTimes(2)
})
})
+2
View File
@@ -39,6 +39,7 @@ import {
TClassicProjectTypeData,
TComponent,
TIssue,
TIssueRelation,
TIssueStatus,
TIssueTemplate,
TIssueTypeData,
@@ -441,6 +442,7 @@ export function createModel (builder: Builder): void {
TProject,
TComponent,
TIssue,
TIssueRelation,
TIssueTemplate,
TIssueStatus,
TTypeIssuePriority,
+16
View File
@@ -47,6 +47,7 @@ import tracker, {
} from '@hcengineering/tracker'
import { classicIssueTaskStatuses } from '.'
import { DOMAIN_TRACKER } from './types'
async function createDefaultProject (tx: TxOperations): Promise<void> {
const current = await tx.findOne(tracker.class.Project, {
@@ -170,6 +171,16 @@ async function migrateIdentifiers (client: MigrationClient): Promise<void> {
}
}
export async function migrateAddStartDate (client: MigrationClient): Promise<void> {
// Issues live in DOMAIN_TASK; Milestones live in DOMAIN_TRACKER.
await client.update(DOMAIN_TASK, { _class: tracker.class.Issue, startDate: { $exists: false } }, { startDate: null })
await client.update(
DOMAIN_TRACKER,
{ _class: tracker.class.Milestone, startDate: { $exists: false } },
{ startDate: null }
)
}
async function migrateDefaultStatuses (client: MigrationClient, logger: ModelLogger): Promise<void> {
const defaultTypeId = tracker.ids.ClassingProjectType
const typeDescriptor = tracker.descriptors.ProjectType
@@ -398,6 +409,11 @@ export const trackerOperation: MigrateOperation = {
state: 'migrateDefaultTypeMixins',
mode: 'upgrade',
func: migrateDefaultTypeMixins
},
{
state: 'gantt-add-startdate',
mode: 'upgrade',
func: migrateAddStartDate
}
])
},
+31
View File
@@ -58,10 +58,12 @@ import time, { type ToDo } from '@hcengineering/time'
import {
type ProjectTargetPreference,
type Component,
type DependencyKind,
type Issue,
type IssueChildInfo,
type IssueParentInfo,
type IssuePriority,
type IssueRelation,
type IssueStatus,
type IssueTemplate,
type IssueTemplateChild,
@@ -233,6 +235,10 @@ export class TIssue extends TTask implements Issue {
@ReadOnly()
declare space: Ref<Project>
@Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.IssueStartDate)
@Index(IndexKind.Indexed)
declare startDate: Timestamp | null
@Prop(TypeDate(DateRangeMode.DATETIME), tracker.string.DueDate)
declare dueDate: Timestamp | null
@@ -340,6 +346,28 @@ export class TTimeSpendReport extends TAttachedDoc implements TimeSpendReport {
@Prop(TypeString(), tracker.string.TimeSpendReportDescription)
description!: string
}
/**
* @public
*/
@Model(tracker.class.IssueRelation, core.class.AttachedDoc, DOMAIN_TRACKER)
@UX(tracker.string.GanttDependency, tracker.icon.Issue)
export class TIssueRelation extends TAttachedDoc implements IssueRelation {
@Prop(TypeRef(tracker.class.Issue), tracker.string.Issue)
declare attachedTo: Ref<Issue>
declare collection: 'relations'
@Prop(TypeRef(tracker.class.Issue), tracker.string.Issue)
@Index(IndexKind.Indexed)
target!: Ref<Issue>
@Prop(TypeString(), tracker.string.GanttDependency)
kind!: DependencyKind
@Prop(TypeNumber(), tracker.string.GanttLag)
lag!: number
}
/**
* @public
*/
@@ -389,6 +417,9 @@ export class TMilestone extends TDoc implements Milestone {
@Prop(Collection(attachment.class.Attachment), attachment.string.Attachments, { shortLabel: attachment.string.Files })
attachments?: number
@Prop(TypeDate(), tracker.string.StartDate)
startDate!: Timestamp | null
@Prop(TypeDate(), tracker.string.TargetDate)
targetDate!: Timestamp
+7 -1
View File
@@ -663,7 +663,7 @@ export function defineViewlets (builder: Builder): void {
viewOptions: milestoneOptions,
configOptions: {
strict: true,
hiddenKeys: ['targetDate', 'label', 'description']
hiddenKeys: ['startDate', 'targetDate', 'label', 'description']
},
config: [
{
@@ -672,6 +672,12 @@ export function defineViewlets (builder: Builder): void {
},
{ key: '', presenter: tracker.component.MilestonePresenter, props: { shouldUseMargin: true } },
{ key: '', displayProps: { grow: true } },
{
key: '',
label: tracker.string.StartDate,
presenter: tracker.component.MilestoneDatePresenter,
props: { field: 'startDate' }
},
{
key: '',
label: tracker.string.TargetDate,
@@ -591,6 +591,7 @@ export class WorkspaceImporter {
rank,
comments: issue.comments?.length ?? 0,
subIssues: issue.subdocs.length,
startDate: null,
dueDate: null,
parents: parentsInfo,
remainingTime,
@@ -14,7 +14,7 @@
</script>
<slot />
<div class="notifications">
<div class="notifications no-print">
{#each Object.entries(positionByClassName) as [className, position]}
<div class={className} style:z-index={9999}>
{#each $store.slice(0, maxVisibleNotifications) as notification (notification.id)}
+1
View File
@@ -257,6 +257,7 @@ const calendarPlugin = plugin(calendarId, {
PersonsLabel: '' as IntlString,
EventNumber: '' as IntlString,
Reminders: '' as IntlString,
Reminder: '' as IntlString,
Today: '' as IntlString,
Visibility: '' as IntlString,
Public: '' as IntlString,
@@ -28,12 +28,12 @@
const client = getClient()
const hierarchy = client.getHierarchy()
let selected: Ref<MasterTag> = value._class
let selected: Ref<MasterTag> | null = value._class
$: mapping = buildMapping(selected, value._class)
async function changeType (): Promise<void> {
if (selected === undefined || selected === value._class) return
if (selected == null || selected === value._class) return
const cloned = hierarchy.clone(value)
applyMapping(cloned, mapping)
const update = fillDefaults(hierarchy, cloned, selected)
@@ -54,8 +54,8 @@
}
}
function buildMapping (selected: Ref<MasterTag> | undefined, current: Ref<MasterTag>): Record<string, AnyAttribute> {
if (selected === undefined || selected === current) return {}
function buildMapping (selected: Ref<MasterTag> | null, current: Ref<MasterTag>): Record<string, AnyAttribute> {
if (selected == null || selected === current) return {}
const selectedAttributes = hierarchy.getAllAttributes(selected, card.class.Card)
const currentAttributes = hierarchy.getAllAttributes(current, card.class.Card)
const res: Record<string, AnyAttribute> = {}
@@ -81,7 +81,7 @@
label={card.string.ChangeType}
okLabel={ui.string.Ok}
okAction={changeType}
canSave={selected !== undefined && selected !== value._class}
canSave={selected != null && selected !== value._class}
gap={'gapV-4'}
on:close={() => {
dispatch('close')
@@ -12,8 +12,13 @@
<!-- limitations under the License. -->
<script lang="ts">
import card, { Card, CardSpace, MasterTag } from '@hcengineering/card'
import presentation, { getClient, getCommunicationClient, SpaceSelector } from '@hcengineering/presentation'
import card, { Card, CardSpace, type CreateCardExtension, MasterTag } from '@hcengineering/card'
import presentation, {
createQuery,
getClient,
getCommunicationClient,
SpaceSelector
} from '@hcengineering/presentation'
import { createEventDispatcher } from 'svelte'
import core, { Data, generateId, Ref, Markup, notEmpty, getCurrentAccount } from '@hcengineering/core'
import { getResource, translate, getEmbeddedLabel } from '@hcengineering/platform'
@@ -24,13 +29,13 @@
import { SelectUsersPopup, employeeByIdStore, permissionsStore } from '@hcengineering/contact-resources'
import view from '@hcengineering/view'
import { createCard, isBaseTypeWithSubtypes } from '../utils'
import { createCard, getRootType, isBaseTypeWithSubtypes } from '../utils'
import CardCollaborators from './CardCollaborators.svelte'
import { TypeSelector } from '../index'
import { canCreateObject } from '@hcengineering/view-resources'
export let title: string = ''
export let type: Ref<MasterTag> = card.types.Document
export let type: Ref<MasterTag> | null = card.types.Document
export let space: Ref<CardSpace> | undefined = undefined
export let changeType: boolean = false
export let allowChangeSpace: boolean = true
@@ -43,18 +48,35 @@
const me = getCurrentEmployee()
const _id = generateId<Card>()
$: extension =
type != null
? client
.getModel()
.findAllSync(card.mixin.CreateCardExtension, {})
.find((it) => hierarchy.isDerived(type, it._id))
: undefined
function getCreateCardExtension (_type: Ref<MasterTag> | null): CreateCardExtension | undefined {
if (_type == null) return undefined
return client
.getModel()
.findAllSync(card.mixin.CreateCardExtension, {})
.find((it) => hierarchy.isDerived(_type, it._id))
}
$: extension = getCreateCardExtension(type)
let data: Partial<Data<Card>> = { title }
let _space: Ref<CardSpace> | undefined = space
let selectedSpace: CardSpace | undefined
let collaborators: Ref<Employee>[] = [me]
const spaceQuery = createQuery()
$: if (_space != null) {
if (selectedSpace?._id !== _space) {
selectedSpace = undefined
}
spaceQuery.query(card.class.CardSpace, { _id: _space }, (result) => {
selectedSpace = result[0]
})
} else {
spaceQuery.unsubscribe()
selectedSpace = undefined
}
let creating = false
async function addCollaborators (): Promise<void> {
@@ -102,12 +124,17 @@
let label: string = ''
$: void updateLabel($languageStore)
$: void updateLabel($languageStore, type)
async function updateLabel (lang: string): Promise<void> {
const _clazz = hierarchy.getClass(type)
const typeString = await translate(_clazz.label, {}, lang)
async function updateLabel (lang: string, _type: Ref<MasterTag> | null): Promise<void> {
const createString = await translate(presentation.string.Create, {}, lang)
if (_type == null) {
label = createString
return
}
const _clazz = hierarchy.getClass(_type)
const typeString = await translate(_clazz.label, {}, lang)
label = `${createString} ${typeString}`
}
@@ -142,8 +169,14 @@
}
}
$: typeAllowedBySpace =
type != null && selectedSpace != null && selectedSpace.types.includes(getRootType(hierarchy, type))
$: allowed =
_space != null && canCreateObject(type, _space, $permissionsStore) && !isBaseTypeWithSubtypes(hierarchy, type)
_space != null &&
type != null &&
typeAllowedBySpace &&
canCreateObject(type, _space, $permissionsStore) &&
!isBaseTypeWithSubtypes(hierarchy, type)
</script>
<Modal
@@ -168,26 +201,28 @@
autoFocus={!(extension?.disableTitle ?? false)}
/>
<AttachmentStyledBox
objectId={_id}
_class={type}
space={_space}
alwaysEdit
showButtons={false}
bind:content={description}
placeholder={core.string.Description}
kind="indented"
isScrollable={true}
kitOptions={{ reference: true }}
enableAttachments={false}
/>
{#if type != null}
<AttachmentStyledBox
objectId={_id}
_class={type}
space={_space}
alwaysEdit
showButtons={false}
bind:content={description}
placeholder={core.string.Description}
kind="indented"
isScrollable={true}
kitOptions={{ reference: true }}
enableAttachments={false}
/>
{/if}
</div>
<div class="hulyModal-content__settingsSet">
{#if changeType}
<div class="hulyModal-content__settingsSet-line">
<span class="label"><Label label={card.string.MasterTag} /></span>
<TypeSelector bind:value={type} excludeBaseTypes />
<TypeSelector bind:value={type} allowedRootTypes={selectedSpace?.types} excludeBaseTypes />
</div>
{/if}
{#if (space == null || allowChangeSpace) && !(extension?.hideSpace ?? false)}
@@ -12,8 +12,8 @@
<!-- limitations under the License. -->
<script lang="ts">
import card, { Card as TypeCard, CardSpace, MasterTag } from '@hcengineering/card'
import presentation, { Card, getClient, SpaceSelector } from '@hcengineering/presentation'
import card, { Card as TypeCard, CardSpace, type CreateCardExtension, MasterTag } from '@hcengineering/card'
import presentation, { Card, createQuery, getClient, SpaceSelector } from '@hcengineering/presentation'
import { createEventDispatcher } from 'svelte'
import core, { Data, generateId, Ref, Markup, getCurrentAccount } from '@hcengineering/core'
import { getResource, translate, getEmbeddedLabel } from '@hcengineering/platform'
@@ -22,12 +22,12 @@
import { permissionsStore } from '@hcengineering/contact-resources'
import view from '@hcengineering/view'
import { createCard, isBaseTypeWithSubtypes } from '../utils'
import { createCard, getRootType, isBaseTypeWithSubtypes } from '../utils'
import { TypeSelector } from '../index'
import { canCreateObject } from '@hcengineering/view-resources'
export let title: string = ''
export let type: Ref<MasterTag> = card.types.Document
export let type: Ref<MasterTag> | null = card.types.Document
export let space: Ref<CardSpace> | undefined = undefined
export let description: Markup = EmptyMarkup
@@ -36,16 +36,33 @@
const hierarchy = client.getHierarchy()
const _id = generateId<TypeCard>()
$: extension =
type != null
? client
.getModel()
.findAllSync(card.mixin.CreateCardExtension, {})
.find((it) => hierarchy.isDerived(type, it._id))
: undefined
function getCreateCardExtension (_type: Ref<MasterTag> | null): CreateCardExtension | undefined {
if (_type == null) return undefined
return client
.getModel()
.findAllSync(card.mixin.CreateCardExtension, {})
.find((it) => hierarchy.isDerived(_type, it._id))
}
$: extension = getCreateCardExtension(type)
const data: Partial<Data<TypeCard>> = { title }
let _space: Ref<CardSpace> | undefined = space
let selectedSpace: CardSpace | undefined
const spaceQuery = createQuery()
$: if (_space != null) {
if (selectedSpace?._id !== _space) {
selectedSpace = undefined
}
spaceQuery.query(card.class.CardSpace, { _id: _space }, (result) => {
selectedSpace = result[0]
})
} else {
spaceQuery.unsubscribe()
selectedSpace = undefined
}
let creating = false
@@ -83,15 +100,26 @@
$: void updateLabel($languageStore, type)
async function updateLabel (lang: string, _type: Ref<MasterTag>): Promise<void> {
async function updateLabel (lang: string, _type: Ref<MasterTag> | null): Promise<void> {
const createString = await translate(presentation.string.Create, {}, lang)
if (_type == null) {
label = createString
return
}
const _clazz = hierarchy.getClass(_type)
const typeString = await translate(_clazz.label, {}, lang)
const createString = await translate(presentation.string.Create, {}, lang)
label = `${createString} ${typeString}`
}
$: typeAllowedBySpace =
type != null && selectedSpace != null && selectedSpace.types.includes(getRootType(hierarchy, type))
$: allowed =
_space != null && canCreateObject(type, _space, $permissionsStore) && !isBaseTypeWithSubtypes(hierarchy, type)
_space != null &&
type != null &&
typeAllowedBySpace &&
canCreateObject(type, _space, $permissionsStore) &&
!isBaseTypeWithSubtypes(hierarchy, type)
</script>
<Card
@@ -119,7 +147,7 @@
kind={'regular'}
size={'large'}
/>
<TypeSelector bind:value={type} excludeBaseTypes />
<TypeSelector bind:value={type} allowedRootTypes={selectedSpace?.types} excludeBaseTypes />
</svelte:fragment>
<ModernEditbox bind:value={data.title} label={view.string.Title} size="medium" kind="ghost" autoFocus />
<svelte:fragment slot="pool">
@@ -20,41 +20,53 @@
import { createEventDispatcher } from 'svelte'
import card from '../plugin'
import view from '@hcengineering/view'
import { getFirstCreatableSubtype, isBaseTypeWithSubtypes } from '../utils'
import { getFirstCreatableSubtype, getRootType, isBaseTypeWithSubtypes } from '../utils'
export let value: Ref<MasterTag>
export let value: Ref<MasterTag> | null
export let width: string | undefined = undefined
export let kind: ButtonKind | undefined = undefined
export let size: ButtonSize | undefined = undefined
export let parent: Ref<MasterTag> = card.class.Card
export let disabled: boolean = false
export let excludeBaseTypes: boolean = false
export let allowedRootTypes: Ref<MasterTag>[] | undefined = undefined
const client = getClient()
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
function filterClasses (): [DropdownIntlItem, DropdownIntlItem[]][] {
const descendants = hierarchy.getDescendants(parent).filter((p) => p !== parent)
function isAllowedBySpace (type: Ref<MasterTag>, roots: Ref<MasterTag>[] | undefined): boolean {
return roots === undefined || roots.includes(getRootType(hierarchy, type))
}
function isSelectableClass (_class: Class<Doc>, roots: Ref<MasterTag>[] | undefined, skipBaseTypes: boolean): boolean {
if (_class.label === undefined) return false
if (_class.kind !== ClassifierKind.CLASS) return false
if ((_class as MasterTag).removed === true) return false
if (!isAllowedBySpace(_class._id as Ref<MasterTag>, roots)) return false
if (skipBaseTypes && isBaseTypeWithSubtypes(hierarchy, _class._id as Ref<MasterTag>)) return false
return true
}
function filterClasses (
root: Ref<MasterTag>,
roots: Ref<MasterTag>[] | undefined,
skipBaseTypes: boolean
): [DropdownIntlItem, DropdownIntlItem[]][] {
const descendants = hierarchy.getDescendants(root).filter((p) => p !== root)
const added = new Set<Ref<Class<Doc>>>()
const base = new Map<Ref<Class<Doc>>, Class<Doc>[]>()
for (const _id of descendants) {
if (added.has(_id)) continue
const _class = hierarchy.getClass(_id)
if (_class.label === undefined) continue
if (_class.kind !== ClassifierKind.CLASS) continue
if ((_class as MasterTag).removed === true) continue
if (excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, _id as Ref<MasterTag>)) continue
if (!isSelectableClass(_class, roots, skipBaseTypes)) continue
added.add(_id)
const descendants = hierarchy.getDescendants(_id)
const toAdd: Class<Doc>[] = []
for (const desc of descendants) {
if (added.has(desc)) continue
const _class = hierarchy.getClass(desc)
if (_class.label === undefined) continue
if (_class.kind !== ClassifierKind.CLASS) continue
if ((_class as MasterTag).removed === true) continue
if (excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, desc as Ref<MasterTag>)) continue
if (!isSelectableClass(_class, roots, skipBaseTypes)) continue
added.add(desc)
toAdd.push(_class)
}
@@ -82,9 +94,10 @@
}
}
const classes = filterClasses()
let classes: [DropdownIntlItem, DropdownIntlItem[]][] = []
$: classes = filterClasses(parent, allowedRootTypes, excludeBaseTypes)
$: if (excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, value)) {
$: if (value != null && excludeBaseTypes && isBaseTypeWithSubtypes(hierarchy, value)) {
const nextType = getFirstCreatableSubtype(hierarchy, value)
if (nextType !== undefined) {
value = nextType
@@ -92,12 +105,20 @@
}
}
$: selectedClass = hierarchy.getClass(value)
$: selected = {
id: selectedClass._id,
label: selectedClass.label,
...getIconProps(selectedClass)
$: if (value != null && !isAllowedBySpace(value, allowedRootTypes)) {
value = null
dispatch('change', value)
}
$: selectedClass = value != null ? hierarchy.getClass(value) : undefined
$: selected =
selectedClass !== undefined
? {
id: selectedClass._id,
label: selectedClass.label,
...getIconProps(selectedClass)
}
: undefined
</script>
<NestedDropdown
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "Pouze platné dokumenty",
"ExportFilterSkipArchivedObsolete": "Vše kromě archivovaných a zastaralých",
"ExportFilterAll": "Všechny dokumenty",
"ExportChildDocuments": "Exportovat podřízené dokumenty",
"ExportChildDocumentsDescription": "Také exportovat dokumenty připojené k vybraným",
"ExportResultRecordTitle": "Export dokumentů z pracovního prostoru {workspace} ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "Nur wirksame Dokumente exportieren",
"ExportFilterSkipArchivedObsolete": "Alle Dokumente außer archivierten und veralteten",
"ExportFilterAll": "Alle Dokumente",
"ExportChildDocuments": "Untergeordnete Dokumente exportieren",
"ExportChildDocumentsDescription": "Auch Dokumente exportieren, die an die ausgewählten angehängt sind",
"ExportResultRecordTitle": "Export von Dokumenten aus dem Arbeitsbereich {workspace} ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "Export only effective documents",
"ExportFilterSkipArchivedObsolete": "All documents except archived and obsolete",
"ExportFilterAll": "All documents",
"ExportChildDocuments": "Export child documents",
"ExportChildDocumentsDescription": "Also export documents attached to the selected ones",
"ExportResultRecordTitle": "Export of documents from {workspace} workspace ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "Solo documentos efectivos",
"ExportFilterSkipArchivedObsolete": "Todos excepto archivados y obsoletos",
"ExportFilterAll": "Todos los documentos",
"ExportChildDocuments": "Exportar documentos secundarios",
"ExportChildDocumentsDescription": "Exportar también los documentos adjuntos a los seleccionados",
"ExportResultRecordTitle": "Exportación de documentos del espacio de trabajo {workspace} ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "Seulement les documents effectifs",
"ExportFilterSkipArchivedObsolete": "Tous sauf archivés et obsolètes",
"ExportFilterAll": "Tous les documents",
"ExportChildDocuments": "Exporter les documents enfants",
"ExportChildDocumentsDescription": "Exporter aussi les documents rattachés aux éléments sélectionnés",
"ExportResultRecordTitle": "Export de documents depuis l'espace de travail {workspace} ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "Solo documenti effettivi",
"ExportFilterSkipArchivedObsolete": "Tutti tranne archiviati e obsoleti",
"ExportFilterAll": "Tutti i documenti",
"ExportChildDocuments": "Esporta documenti figlio",
"ExportChildDocumentsDescription": "Esporta anche i documenti collegati a quelli selezionati",
"ExportResultRecordTitle": "Esportazione di documenti dallo spazio di lavoro {workspace} ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "有効なドキュメントのみ",
"ExportFilterSkipArchivedObsolete": "アーカイブ・廃止を除くすべて",
"ExportFilterAll": "すべてのドキュメント",
"ExportChildDocuments": "子ドキュメントをエクスポート",
"ExportChildDocumentsDescription": "選択したドキュメントに添付されたドキュメントもエクスポートします",
"ExportResultRecordTitle": "ワークスペース {workspace} からのドキュメントのエクスポート ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "유효한 문서만",
"ExportFilterSkipArchivedObsolete": "보관/폐기 제외 전체",
"ExportFilterAll": "모든 문서",
"ExportChildDocuments": "하위 문서 내보내기",
"ExportChildDocumentsDescription": "선택한 문서에 첨부된 문서도 함께 내보냅니다",
"ExportResultRecordTitle": "{workspace} 워크스페이스 문서 내보내기 ({date})"
}
}
+2
View File
@@ -54,6 +54,8 @@
"ExportFilterEffectiveOnly": "Apenas documentos efetivos",
"ExportFilterSkipArchivedObsolete": "Todos exceto arquivados e obsoletos",
"ExportFilterAll": "Todos os documentos",
"ExportChildDocuments": "Exportar documentos filhos",
"ExportChildDocumentsDescription": "Exportar também os documentos anexados aos selecionados",
"ExportResultRecordTitle": "Exportação de documentos do espaço de trabalho {workspace} ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "Apenas documentos efetivos",
"ExportFilterSkipArchivedObsolete": "Todos exceto arquivados e obsoletos",
"ExportFilterAll": "Todos os documentos",
"ExportChildDocuments": "Exportar documentos filhos",
"ExportChildDocumentsDescription": "Exportar também os documentos anexados aos selecionados",
"ExportResultRecordTitle": "Exportação de documentos do espaço de trabalho {workspace} ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "Только действующие документы",
"ExportFilterSkipArchivedObsolete": "Все кроме архивных и устаревших",
"ExportFilterAll": "Все документы",
"ExportChildDocuments": "Экспортировать дочерние документы",
"ExportChildDocumentsDescription": "Также экспортировать документы, прикреплённые к выбранным",
"ExportResultRecordTitle": "Экспорт документов из пространства {workspace} ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "Yalnızca geçerli belgeler",
"ExportFilterSkipArchivedObsolete": "Arşivlenmiş ve geçersiz hariç tümü",
"ExportFilterAll": "Tüm belgeler",
"ExportChildDocuments": "Alt belgeleri dışa aktar",
"ExportChildDocumentsDescription": "Seçili belgelere ekli belgeleri de dışa aktar",
"ExportResultRecordTitle": "{workspace} çalışma alanından belge dışa aktarma ({date})"
}
}
+2
View File
@@ -53,6 +53,8 @@
"ExportFilterEffectiveOnly": "仅有效文档",
"ExportFilterSkipArchivedObsolete": "除已归档和已过时外的全部",
"ExportFilterAll": "全部文档",
"ExportChildDocuments": "导出子文档",
"ExportChildDocumentsDescription": "同时导出附加到所选文档上的文档",
"ExportResultRecordTitle": "从工作区 {workspace} 导出文档 ({date})"
}
}
@@ -25,7 +25,7 @@
type Class
} from '@hcengineering/core'
import { Card, getClient, getCurrentWorkspaceUuid } from '@hcengineering/presentation'
import { DropdownLabels, DropdownLabelsIntl, Label } from '@hcengineering/ui'
import { DropdownLabels, DropdownLabelsIntl, Label, ToggleWithLabel } from '@hcengineering/ui'
import { getResource } from '@hcengineering/platform'
import login from '@hcengineering/login'
import { shouldSkipDocument, isEffectiveDocument } from '@hcengineering/export'
@@ -52,6 +52,11 @@
type ExportFilterMode = 'effectiveOnly' | 'skipArchivedObsolete' | 'all'
let exportFilterMode: ExportFilterMode = 'effectiveOnly'
// Whether to recursively export child (collection) documents of the selected docs.
// Hidden and forced to true when exporting an entire space — the space already
// enumerates every document inside it.
let includeChildren: boolean = false
const exportFilterItems = [
{ id: 'effectiveOnly' as const, label: plugin.string.ExportFilterEffectiveOnly },
{ id: 'skipArchivedObsolete' as const, label: plugin.string.ExportFilterSkipArchivedObsolete },
@@ -142,6 +147,10 @@
const effectiveDocs = projectDocExport === true ? await getExportDocuments() : filteredSelectedDocs
// When exporting an entire space, child documents are picked up by the
// space-wide traversal — force the flag on regardless of UI state.
const effectiveIncludeChildren = spaceExport === true ? true : includeChildren
void exportToWorkspace(
_class,
exportQuery,
@@ -149,7 +158,8 @@
targetWorkspace,
undefined,
exportFilterMode === 'skipArchivedObsolete',
exportFilterMode === 'effectiveOnly'
exportFilterMode === 'effectiveOnly',
effectiveIncludeChildren
)
loading = false
dispatch('close', true)
@@ -191,5 +201,14 @@
<Label label={plugin.string.ExportFilterMode} />
</span>
<DropdownLabelsIntl items={exportFilterItems} bind:selected={exportFilterMode} kind="regular" size="large" />
{#if spaceExport !== true}
<div class="pl-2 py-4">
<ToggleWithLabel
label={plugin.string.ExportChildDocuments}
description={plugin.string.ExportChildDocumentsDescription}
bind:on={includeChildren}
/>
</div>
{/if}
</div>
</Card>
+4 -2
View File
@@ -26,7 +26,8 @@ export async function exportToWorkspace (
targetWorkspace: string | undefined,
relations?: RelationDefinition[] | undefined,
skipDeletedObsolete?: boolean,
exportOnlyEffective?: boolean
exportOnlyEffective?: boolean,
includeChildren?: boolean
): Promise<void> {
const lang = getCurrentLanguage()
@@ -73,7 +74,8 @@ export async function exportToWorkspace (
_class,
fieldMappers,
skipDeletedObsolete,
exportOnlyEffective
exportOnlyEffective,
includeChildren
}
if (relations != null) {
body.relations = relations
+3 -1
View File
@@ -56,6 +56,8 @@ export default mergeIds(exportId, exportPlugin, {
ExportFilterMode: '' as IntlString,
ExportFilterEffectiveOnly: '' as IntlString,
ExportFilterSkipArchivedObsolete: '' as IntlString,
ExportFilterAll: '' as IntlString
ExportFilterAll: '' as IntlString,
ExportChildDocuments: '' as IntlString,
ExportChildDocumentsDescription: '' as IntlString
}
})
@@ -44,7 +44,7 @@
type
}
if (key !== undefined) {
const attr = client.getHierarchy().findAttribute(process.masterTag, key)
const attr = client.getModel().findAllSync(core.class.Attribute, { name: key })[0]
if (attr?.label !== undefined) {
name = await translate(attr.label, {})
result.name = name
+1 -2
View File
@@ -243,10 +243,9 @@ export class ProcessMiddleware extends BasePresentationMiddleware implements Pre
results = await Promise.all(
results.map(async (r) => {
if (r.key !== undefined) {
const h = this.client.getHierarchy()
const _process = this.client.getModel().findObject(execution.process)
if (_process !== undefined) {
const attr = h.findAttribute(_process.masterTag, r.key)
const attr = this.client.getModel().findAllSync(core.class.Attribute, { name: r.key })[0]
if (attr?.label !== undefined) {
const name = await translate(attr.label, {})
return { ...r, name }
@@ -15,7 +15,7 @@
<script lang="ts">
import { Analytics } from '@hcengineering/analytics'
import { AccessLevel, Calendar, generateEventId } from '@hcengineering/calendar'
import { VisibilityEditor } from '@hcengineering/calendar-resources'
import { EventReminders, VisibilityEditor } from '@hcengineering/calendar-resources'
import calendar from '@hcengineering/calendar-resources/src/plugin'
import { getCurrentEmployee } from '@hcengineering/contact'
import core, { AttachedData, Doc, Ref, SortingOrder, generateId, getCurrentAccount } from '@hcengineering/core'
@@ -104,7 +104,7 @@
allDay: false,
access: AccessLevel.Owner,
visibility: todo.visibility === 'public' ? 'public' : 'freeBusy',
reminders: [],
reminders,
user: myAccount.primarySocialId
})
Analytics.handleEvent(TimeEvents.ToDoScheduled, { id })
@@ -126,6 +126,7 @@
})
let slots: WorkSlot[] = []
let reminders: number[] = []
function removeSlot (e: CustomEvent<{ _id: Ref<WorkSlot> }>): void {
const index = slots.findIndex((p) => p._id === e.detail._id)
@@ -151,7 +152,7 @@
blockTime: true,
access: AccessLevel.Owner,
visibility: todo.visibility,
reminders: [],
reminders,
calendar: _calendar,
space: calendar.space.Calendar,
_id: generateId(),
@@ -267,6 +268,9 @@
on:change={changeSlot}
on:dueChange={changeDueSlot}
/>
{#if slots.length > 0}
<EventReminders bind:reminders />
{/if}
</div>
<div class="flex-row-reverse btn flex-no-shrink">
<Button
@@ -13,7 +13,8 @@
// limitations under the License.
-->
<script lang="ts">
import calendar, { AccessLevel, Calendar, generateEventId } from '@hcengineering/calendar'
import calendar, { AccessLevel, generateEventId } from '@hcengineering/calendar'
import { EventReminders } from '@hcengineering/calendar-resources'
import contact, { getCurrentEmployee } from '@hcengineering/contact'
import { Ref, getCurrentAccount } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
@@ -31,11 +32,39 @@
const query = createQuery()
let slots: WorkSlot[] = []
let reminders: number[] = []
let remindersHydrated = false
let remindersKey = ''
let currentTodoId: Ref<ToDo> | undefined
$: if (todo?._id !== undefined && currentTodoId !== todo._id) {
currentTodoId = todo._id
remindersHydrated = false
reminders = []
remindersKey = ''
}
$: query.query(time.class.WorkSlot, { attachedTo: todo._id }, (res) => {
slots = res
if (!remindersHydrated) {
reminders = [...(slots[0]?.reminders ?? [])]
remindersHydrated = true
remindersKey = JSON.stringify(reminders)
}
})
$: {
if (!remindersHydrated || slots.length === 0) {
// no-op
} else {
const nextKey = JSON.stringify(reminders)
if (nextKey !== remindersKey) {
remindersKey = nextKey
void syncRemindersForSlots()
}
}
}
async function change (e: CustomEvent<{ startDate: number, dueDate: number, slot: Ref<WorkSlot> }>): Promise<void> {
const { startDate, dueDate, slot } = e.detail
const workslot = slots.find((s) => s._id === slot)
@@ -72,14 +101,22 @@
access: AccessLevel.Owner,
user: currentAccount.primarySocialId,
visibility: todo.visibility === 'public' ? 'public' : 'freeBusy',
reminders: []
reminders
})
Analytics.handleEvent(TimeEvents.ToDoScheduled, { id: todo._id })
}
async function syncRemindersForSlots (): Promise<void> {
await Promise.all(
slots.map(async (slot) => {
await client.update(slot, { reminders })
})
)
}
async function remove (e: CustomEvent<{ _id: Ref<WorkSlot> }>): Promise<void> {
const object = slots.find((p) => p._id === e.detail._id)
if (object) {
if (object !== undefined) {
showPopup(
contact.component.DeleteConfirmationPopup,
{
@@ -98,4 +135,11 @@
}
</script>
<Workslots {slots} fixed={'toDo'} on:change={change} on:dueChange={dueChange} on:create={create} on:remove={remove} />
<div class="flex-col">
<Workslots {slots} fixed={'toDo'} on:change={change} on:dueChange={dueChange} on:create={create} on:remove={remove} />
{#if slots.length > 0}
<div class="flex pt-4">
<EventReminders bind:reminders />
</div>
{/if}
</div>
+3
View File
@@ -115,6 +115,9 @@
"NoAssignee": "Bez přiřazení",
"LastUpdated": "Poslední aktualizace",
"DueDate": "Datum splnění",
"IssueStartDate": "Datum zahájení",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "Manuální",
"All": "Vše",
"PastWeek": "Minulý týden",
+3
View File
@@ -125,6 +125,9 @@
"NoAssignee": "Nicht zugewiesen",
"LastUpdated": "Zuletzt aktualisiert",
"DueDate": "Fälligkeitsdatum",
"IssueStartDate": "Startdatum",
"GanttDependency": "Abhängigkeit",
"GanttLag": "Verzögerung",
"Manual": "Manuell",
"All": "Alle",
"PastWeek": "Letzte Woche",
+3
View File
@@ -125,6 +125,9 @@
"NoAssignee": "No assignee",
"LastUpdated": "Last updated",
"DueDate": "Due date",
"IssueStartDate": "Start date",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "Manual",
"All": "All",
"PastWeek": "Past week",
+3
View File
@@ -123,6 +123,9 @@
"NoAssignee": "Sin asignar",
"LastUpdated": "Última actualización",
"DueDate": "Fecha de vencimiento",
"IssueStartDate": "Fecha de inicio",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "Manual",
"All": "Todos",
"PastWeek": "Semana pasada",
+3
View File
@@ -123,6 +123,9 @@
"NoAssignee": "Non assigné",
"LastUpdated": "Dernière mise à jour",
"DueDate": "Date d'échéance",
"IssueStartDate": "Date de début",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "Manuel",
"All": "Tous",
"PastWeek": "La semaine passée",
+3
View File
@@ -123,6 +123,9 @@
"NoAssignee": "Nessun assegnatario",
"LastUpdated": "Ultimo aggiornamento",
"DueDate": "Data di scadenza",
"IssueStartDate": "Data di inizio",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "Manuale",
"All": "Tutti",
"PastWeek": "Settimana scorsa",
+3
View File
@@ -123,6 +123,9 @@
"NoAssignee": "担当者なし",
"LastUpdated": "最終更新日",
"DueDate": "期日",
"IssueStartDate": "開始日",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "手動",
"All": "すべて",
"PastWeek": "先週",
+3
View File
@@ -123,6 +123,9 @@
"NoAssignee": "담당자 없음",
"LastUpdated": "최근 업데이트",
"DueDate": "마감일",
"IssueStartDate": "시작일",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "수동",
"All": "전체",
"PastWeek": "지난주",
+3
View File
@@ -123,6 +123,9 @@
"NoAssignee": "Sem atribuição",
"LastUpdated": "Última atualização",
"DueDate": "Data de vencimento",
"IssueStartDate": "Data de início",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "Manual",
"All": "Todos",
"PastWeek": "Semana passada",
+3
View File
@@ -123,6 +123,9 @@
"NoAssignee": "Sem atribuição",
"LastUpdated": "Última atualização",
"DueDate": "Data de vencimento",
"IssueStartDate": "Data de início",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "Manual",
"All": "Todos",
"PastWeek": "Semana passada",
+3
View File
@@ -125,6 +125,9 @@
"NoAssignee": "Нет исполнителя",
"LastUpdated": "Последнее обновление",
"DueDate": "Срок",
"IssueStartDate": "Дата начала",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "Пользовательский",
"All": "Все",
"PastWeek": "Предыдущая неделя",
+3
View File
@@ -123,6 +123,9 @@
"NoAssignee": "Atanan yok",
"LastUpdated": "Son güncelleme",
"DueDate": "Bitiş tarihi",
"IssueStartDate": "Başlangıç tarihi",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "Manuel",
"All": "Tümü",
"PastWeek": "Geçen hafta",
+3
View File
@@ -125,6 +125,9 @@
"NoAssignee": "无受理人",
"LastUpdated": "最后更新",
"DueDate": "截止日期",
"IssueStartDate": "开始日期",
"GanttDependency": "Dependency",
"GanttLag": "Lag",
"Manual": "手动",
"All": "全部",
"PastWeek": "过去一周",
@@ -186,6 +186,7 @@
priority: priority ?? IssuePriority.NoPriority,
space: _space as Ref<Project>,
component: component ?? $activeComponent ?? null,
startDate: null,
dueDate: null,
attachments: 0,
estimation: 0,
@@ -312,6 +313,7 @@
_id: generateId(),
space: _space as Ref<Project>,
subIssues: [],
startDate: null,
dueDate: null,
labels:
p.labels !== undefined
@@ -488,6 +490,7 @@
rank: '',
comments: 0,
subIssues: 0,
startDate: object.startDate,
dueDate: object.dueDate,
parents:
parentIssue != null
@@ -86,6 +86,7 @@
rank: '',
comments: 0,
subIssues: 0,
startDate: subIssue.startDate ?? null,
dueDate: null,
parents,
reportedTime: 0,
@@ -0,0 +1,53 @@
<!--
// 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.
-->
<script lang="ts">
import { WithLookup } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Issue } from '@hcengineering/tracker'
import { DueDatePresenter } from '@hcengineering/ui'
export let value: WithLookup<Issue>
export let width: string | undefined = undefined
export let editable: boolean = true
const client = getClient()
const handleStartDateChanged = async (newStartDate: number | undefined | null): Promise<void> => {
if (newStartDate === undefined || value.startDate === newStartDate) {
return
}
await client.updateCollection(
value._class,
value.space,
value._id,
value.attachedTo,
value.attachedToClass,
value.collection,
{ startDate: newStartDate }
)
}
</script>
{#if value}
<DueDatePresenter
kind={'link'}
value={value.startDate}
{width}
{editable}
onChange={(e) => handleStartDateChanged(e)}
shouldIgnoreOverdue={true}
/>
{/if}
@@ -35,6 +35,7 @@
import DueDateEditor from '../DueDateEditor.svelte'
import PriorityEditor from '../PriorityEditor.svelte'
import RelationEditor from '../RelationEditor.svelte'
import StartDateEditor from '../StartDateEditor.svelte'
import StatusEditor from '../StatusEditor.svelte'
import notification from '@hcengineering/notification'
@@ -60,6 +61,7 @@
'number',
'assignee',
'component',
'startDate',
'dueDate',
'milestone',
'relations',
@@ -202,14 +204,17 @@
</span>
<MilestoneEditor value={issue} space={issue.space} size={'medium'} isEditable={!readonly} />
{#if issue.dueDate !== null}
<div class="divider" />
<div class="divider" />
<span class="labelOnPanel">
<Label label={tracker.string.DueDate} />
</span>
<DueDateEditor value={issue} width={'100%'} editable={!readonly} />
{/if}
<span class="labelOnPanel">
<Label label={tracker.string.IssueStartDate} />
</span>
<StartDateEditor value={issue} width={'100%'} editable={!readonly} />
<span class="labelOnPanel">
<Label label={tracker.string.DueDate} />
</span>
<DueDateEditor value={issue} width={'100%'} editable={!readonly} />
{#if keys.length > 0}
<div class="divider" />
@@ -16,9 +16,10 @@
import { AttachmentStyleBoxEditor } from '@hcengineering/attachment-resources'
import { getClient } from '@hcengineering/presentation'
import { Milestone } from '@hcengineering/tracker'
import { EditBox, Label } from '@hcengineering/ui'
import { DatePresenter, EditBox, Label } from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
import tracker from '../../plugin'
import MilestoneStatusEditor from './MilestoneStatusEditor.svelte'
import QueryIssuesList from '../issues/edit/QueryIssuesList.svelte'
export let object: Milestone
@@ -33,12 +34,27 @@
await client.update(object, { [field]: value })
}
async function changeStartDate (value: number | null | undefined): Promise<void> {
await client.update(object, { startDate: value ?? null })
}
async function changeTargetDate (value: number | null | undefined): Promise<void> {
if (value === null || value === undefined) return
await client.update(object, { targetDate: value })
}
$: if (oldLabel !== object.label) {
oldLabel = object.label
rawLabel = object.label
}
onMount(() => dispatch('open', { ignoreKeys: ['label', 'description', 'attachments'] }))
// status / startDate / targetDate are rendered in this component's body in
// chronological order (Status → Start → Target). Hide them from the
// auto-generated side panel so they don't appear twice.
onMount(() =>
dispatch('open', {
ignoreKeys: ['label', 'description', 'attachments', 'status', 'startDate', 'targetDate']
})
)
$: descriptionKey = client.getHierarchy().getAttribute(tracker.class.Component, 'description')
let descriptionBox: AttachmentStyleBoxEditor
</script>
@@ -58,6 +74,37 @@
}}
/>
<div class="dates-row mt-4">
<div class="date-cell">
<span class="cell-label"><Label label={tracker.string.Status} /></span>
<MilestoneStatusEditor value={object.status} {object} kind="regular" />
</div>
<div class="date-cell">
<span class="cell-label"><Label label={tracker.string.StartDate} /></span>
<DatePresenter
value={object.startDate}
editable
kind={'regular'}
size={'medium'}
on:change={(e) => {
void changeStartDate(e.detail)
}}
/>
</div>
<div class="date-cell">
<span class="cell-label"><Label label={tracker.string.TargetDate} /></span>
<DatePresenter
value={object.targetDate}
editable
kind={'regular'}
size={'medium'}
on:change={(e) => {
void changeTargetDate(e.detail)
}}
/>
</div>
</div>
<div class="w-full mt-6">
<AttachmentStyleBoxEditor
focusIndex={30}
@@ -83,3 +130,23 @@
</svelte:fragment>
</QueryIssuesList>
</div>
<style lang="scss">
.dates-row {
display: flex;
gap: 1.5rem;
align-items: flex-start;
flex-wrap: wrap;
}
.date-cell {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 8rem;
}
.cell-label {
font-size: 0.85rem;
color: var(--theme-darker-color);
font-weight: 500;
}
</style>
@@ -34,6 +34,7 @@
status: MilestoneStatus.Planned,
comments: 0,
attachments: 0,
startDate: null,
targetDate: Date.now() + 14 * 24 * 60 * 60 * 1000
}
@@ -76,6 +77,14 @@
/>
<svelte:fragment slot="pool">
<MilestoneStatusEditor bind:value={object.status} {object} kind="regular" />
<DatePresenter
bind:value={object.startDate}
editable
label={tracker.string.StartDate}
detail={ui.string.SelectDate}
kind={'regular'}
size={'large'}
/>
<DatePresenter
bind:value={object.targetDate}
editable
@@ -71,6 +71,7 @@
assignee: project.defaultAssignee ?? null,
status: project.defaultIssueStatus,
space: project._id,
startDate: null,
dueDate: null,
subIssues: [],
attachments: 0,
+35
View File
@@ -119,6 +119,18 @@ export enum IssuePriority {
Low
}
/**
* Dependency kind between two Issues for Gantt scheduling.
*
* - `finish-to-start` (FS): A must finish before B can start. Most common.
* - `start-to-start` (SS): A must start before B can start.
* - `finish-to-finish` (FF): A must finish before B can finish.
* - `start-to-finish` (SF): A must start before B can finish. Rare.
*
* @public
*/
export type DependencyKind = 'finish-to-start' | 'start-to-start' | 'finish-to-finish' | 'start-to-finish'
/**
* @public
*/
@@ -175,6 +187,7 @@ export interface Milestone extends Doc {
comments: number
attachments?: number
startDate: Timestamp | null // null = open-ended begin marker
targetDate: Timestamp
}
@@ -196,6 +209,8 @@ export interface Issue extends Task {
relations?: RelatedDocument[]
parents: IssueParentInfo[]
startDate: Timestamp | null // for Gantt scheduling; null = unscheduled
space: Ref<Project>
milestone?: Ref<Milestone> | null
@@ -236,6 +251,7 @@ export interface IssueDraft {
assignee: Ref<Person> | null
component: Ref<Component> | null
space: Ref<Project>
startDate: Timestamp | null
dueDate: Timestamp | null
milestone?: Ref<Milestone> | null
@@ -323,6 +339,21 @@ export interface IssueParentInfo {
space: Ref<Space>
}
/**
* Typed dependency between two Issues, used by the Gantt view to compute
* cascade scheduling and critical path.
*
* Persisted as an AttachedDoc collection 'relations' on the source Issue.
*
* @public
*/
export interface IssueRelation extends AttachedDoc<Issue, 'relations'> {
target: Ref<Issue> // successor
kind: DependencyKind
/** Lag in schedule days; can be negative (overlap). */
lag: number
}
/**
* @public
*/
@@ -366,6 +397,7 @@ const pluginState = plugin(trackerId, {
class: {
Project: '' as Ref<Class<Project>>,
Issue: '' as Ref<Class<Issue>>,
IssueRelation: '' as Ref<Class<IssueRelation>>,
IssueTemplate: '' as Ref<Class<IssueTemplate>>,
Component: '' as Ref<Class<Component>>,
IssueStatus: '' as Ref<Class<IssueStatus>>,
@@ -520,6 +552,9 @@ const pluginState = plugin(trackerId, {
Project: '' as IntlString,
RelatedIssues: '' as IntlString,
Issue: '' as IntlString,
IssueStartDate: '' as IntlString,
GanttDependency: '' as IntlString,
GanttLag: '' as IntlString,
NewProject: '' as IntlString,
UnsetParentIssue: '' as IntlString,
ForbidCreateProjectPermission: '' as IntlString,
@@ -47,7 +47,20 @@
const refs: HTMLElement[] = []
$: refs.length = objects.length
$: uniqueObjects = deduplicate(objects)
function deduplicate (list: Doc[] | undefined): Doc[] {
if (!list) return []
const seen = new Set<string>()
return list.filter((item) => {
if (item?._id == null) return false
if (seen.has(item._id)) return false
seen.add(item._id)
return true
})
}
$: refs.length = uniqueObjects.length
$: viewlet = getViewlet(_class)
@@ -210,9 +223,9 @@
</tr>
</thead>
{/if}
{#if objects.length > 0}
{#if uniqueObjects.length > 0}
<tbody>
{#each objects as object, row (object._id)}
{#each uniqueObjects as object, row (object._id)}
<tr
class="antiTable-body__row"
class:fixed={row === selection}
@@ -23,6 +23,19 @@
$: _class = direction === 'B' ? association.classB : association.classA
$: uniqueDocs = deduplicate(docs)
function deduplicate (list: Doc[] | undefined): Doc[] {
if (!list) return []
const seen = new Set<string>()
return list.filter((item) => {
if (item?._id == null) return false
if (seen.has(item._id)) return false
seen.add(item._id)
return true
})
}
function getCreate (): ObjectCreate | undefined {
const factory = client.getHierarchy().classHierarchyMixin(_class, view.mixin.ObjectFactory)
if (factory) {
@@ -53,7 +66,7 @@
function add (): void {
const create = getCreate()
const isVersionable = client.getHierarchy().classHierarchyMixin(_class, core.mixin.VersionableClass) !== undefined
const baseQuery = { _id: { $nin: docs.map((p) => p._id) } }
const baseQuery = { _id: { $nin: uniqueDocs.map((p) => p._id) } }
const docQuery = isVersionable ? { isLatest: true, ...baseQuery } : baseQuery
showPopup(
ObjectBoxPopup,
@@ -120,7 +133,7 @@
return direction === 'B'
}
$: allowToCreate = isAllowedToCreate(association, docs, direction)
$: allowToCreate = isAllowedToCreate(association, uniqueDocs, direction)
$: classLabel = client.getHierarchy().getClass(_class).label
</script>
@@ -139,9 +152,9 @@
</svelte:fragment>
<svelte:fragment slot="content">
{#if docs?.length > 0 && config != null}
{#if uniqueDocs?.length > 0 && config != null}
<Scroller horizontal>
<DocTable objects={docs} {_class} {config} {onContextMenu} />
<DocTable objects={uniqueDocs} {_class} {config} {onContextMenu} />
</Scroller>
{:else if !readonly}
<div
@@ -252,6 +252,67 @@
return typeof value === 'string' ? value : value?.key
}
function getAttributeKey (key: string): string {
if (key.startsWith('$lookup.')) {
return key.slice('$lookup.'.length)
}
const dotIndex = key.lastIndexOf('.')
return dotIndex === -1 ? key : key.slice(dotIndex + 1)
}
function isSourceAttribute (sourceClass: Ref<Class<Doc>>, key: string): boolean {
return hierarchy.getAllAttributes(sourceClass).has(getAttributeKey(key))
}
function syncConfigOrder (
sourceClass: Ref<Class<Doc>>,
previousSourceConfig: Array<BuildModelKey | string>,
sourceConfig: Array<BuildModelKey | string>,
targetConfig: Array<BuildModelKey | string>
): Array<BuildModelKey | string> {
const sourceKeys = new Set(sourceConfig.map(getKey).filter((it): it is string => it !== undefined))
const previousSourceKeys = new Set(previousSourceConfig.map(getKey).filter((it): it is string => it !== undefined))
const targetByKey = new Map<string, Array<{ item: BuildModelKey | string, index: number }>>()
for (const [index, item] of targetConfig.entries()) {
const key = getKey(item)
if (key === undefined) continue
const items = targetByKey.get(key) ?? []
items.push({ item, index })
targetByKey.set(key, items)
}
const sourceItems: Array<BuildModelKey | string> = []
const usedIndexes = new Set<number>()
for (const sourceItem of sourceConfig) {
const key = getKey(sourceItem)
if (key === undefined) continue
const targetItem = targetByKey.get(key)?.shift()
sourceItems.push(targetItem?.item ?? sourceItem)
if (targetItem !== undefined) {
usedIndexes.add(targetItem.index)
}
}
const synced = [...sourceItems]
for (const [index, targetItem] of targetConfig.entries()) {
if (usedIndexes.has(index)) continue
const key = getKey(targetItem)
if (
key !== undefined &&
!sourceKeys.has(key) &&
(previousSourceKeys.has(key) || isSourceAttribute(sourceClass, key))
) {
continue
}
synced.splice(Math.min(index, synced.length), 0, targetItem)
}
return synced
}
function isExist (result: Config[], newValue: Config): boolean {
if (!isAttribute(newValue)) return false
const newValueKey = getKey(newValue.value)
@@ -391,6 +452,45 @@
return preference === undefined ? result : setStatus(result, preference)
}
async function upsertViewletPreference (
viewletId: Ref<Viewlet>,
config: Array<BuildModelKey | string>
): Promise<void> {
const preference = preferences.find((p) => p.attachedTo === viewletId)
if (preference !== undefined) {
if (!deepEqual(preference.config, config)) {
await client.update(preference, {
config
})
}
} else {
await client.createDoc(view.class.ViewletPreference, core.space.Workspace, {
attachedTo: viewletId,
config
})
}
}
async function syncChildViewletPreferences (
sourceViewlet: Viewlet,
previousSourceConfig: Array<BuildModelKey | string>,
sourceConfig: Array<BuildModelKey | string>
): Promise<void> {
const descendants = new Set(
hierarchy.getDescendants(sourceViewlet.attachTo).filter((it) => it !== sourceViewlet.attachTo)
)
for (const childViewlet of viewlets) {
if (!descendants.has(childViewlet.attachTo)) continue
const preference = preferences.find((p) => p.attachedTo === childViewlet._id)
const targetConfig = preference?.config ?? childViewlet.config
const config = syncConfigOrder(sourceViewlet.attachTo, previousSourceConfig, sourceConfig, targetConfig)
if (deepEqual(targetConfig, config)) continue
await upsertViewletPreference(childViewlet._id, config)
}
}
async function addAssociations (
result: Config[],
_class: Ref<Class<Doc>>,
@@ -437,16 +537,14 @@
}
return value
})
const preference = preferences.find((p) => p.attachedTo === viewletId)
if (preference !== undefined) {
await client.update(preference, {
config
})
} else {
await client.createDoc(view.class.ViewletPreference, core.space.Workspace, {
attachedTo: viewletId,
config
})
const selectedViewlet = viewlets.find((it) => it._id === viewletId)
const previousSourceConfig =
preferences.find((p) => p.attachedTo === viewletId)?.config ?? selectedViewlet?.config ?? []
await upsertViewletPreference(viewletId, config)
if (selectedViewlet !== undefined) {
await syncChildViewletPreferences(selectedViewlet, previousSourceConfig, config)
}
}
@@ -13,7 +13,7 @@
// limitations under the License.
-->
<script lang="ts">
import { Class, Doc, DocumentQuery, Ref, Space, getCurrentAccount } from '@hcengineering/core'
import { AccountRole, Class, Doc, DocumentQuery, Ref, Space, getCurrentAccount } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import { getClient, reduceCalls } from '@hcengineering/presentation'
import { Button, IconAdd, eventToHTMLElement, getCurrentLocation, showPopup } from '@hcengineering/ui'
@@ -37,6 +37,9 @@
const hierarchy = client.getHierarchy()
const dispatch = createEventDispatcher()
const account = getCurrentAccount()
const canSaveFilteredView = account.role !== AccountRole.ReadOnlyGuest && account.role !== AccountRole.DocGuest
function onChange (e: Filter | undefined) {
if (e === undefined) return
updateFilter(e)
@@ -189,7 +192,7 @@
</div>
</div>
{#if !hideSaveButtons}
{#if !hideSaveButtons && canSaveFilteredView}
<div class="flex gap-1-5">
<Button
icon={view.icon.Views}
@@ -1,5 +1,5 @@
<script lang="ts">
import core, { Class, Doc, Ref, getCurrentAccount } from '@hcengineering/core'
import core, { Class, Doc, Ref, Space, getCurrentAccount } from '@hcengineering/core'
import { Card, getClient } from '@hcengineering/presentation'
import { Button, EditBox, ToggleWithLabel, getCurrentResolvedLocation } from '@hcengineering/ui'
import { ViewOptions } from '@hcengineering/view'
@@ -16,11 +16,18 @@
let filterName = ''
const client = getClient()
async function getTargetSpace (spaceRef: string | undefined): Promise<Ref<Space>> {
// Store the filtered view in the space it is built on
if (spaceRef === undefined) return core.space.Workspace
const space = await client.findOne(core.class.Space, { _id: spaceRef as Ref<Space> })
return space?._id ?? core.space.Workspace
}
async function saveFilter () {
const loc = getCurrentResolvedLocation()
loc.fragment = undefined
const filters = JSON.stringify($filterStore)
await client.createDoc(view.class.FilteredView, core.space.Workspace, {
await client.createDoc(view.class.FilteredView, await getTargetSpace(loc.path[3]), {
name: filterName,
location: loc,
filterClass: _class,
@@ -1,6 +1,6 @@
<script lang="ts">
import contact from '@hcengineering/contact'
import { Ref, getCurrentAccount, toIdMap } from '@hcengineering/core'
import { AccountRole, Ref, getCurrentAccount, toIdMap } from '@hcengineering/core'
import { copyTextToClipboard, createQuery, getClient } from '@hcengineering/presentation'
import setting from '@hcengineering/setting'
import {
@@ -42,11 +42,12 @@
const dispatch = createEventDispatcher()
const client = getClient()
const myAcc = getCurrentAccount()
const canUseFilteredViews = myAcc.role !== AccountRole.ReadOnlyGuest && myAcc.role !== AccountRole.DocGuest
const filteredViewsQuery = createQuery()
let availableFilteredViews: FilteredView[] = []
let myFilteredViews: FilteredView[] = []
$: if (alias !== undefined) {
$: if (alias !== undefined && canUseFilteredViews) {
filteredViewsQuery.query<FilteredView>(view.class.FilteredView, { attachedTo: alias }, (result) => {
myFilteredViews = result.filter((p) => p.users.includes(myAcc.uuid))
availableFilteredViews = result.filter((p) => p.sharable && !p.users.includes(myAcc.uuid))
+21 -10
View File
@@ -37,7 +37,7 @@ import { promisify } from 'util'
import { gzip } from 'zlib'
import { retrieveJson } from './utils'
import { unknownError } from '@hcengineering/platform'
import platform, { PlatformError, unknownError } from '@hcengineering/platform'
export const COMMUNICATION_DOMAIN = 'communication' as OperationDomain
interface RPCClientInfo {
@@ -270,15 +270,26 @@ export function registerRPC (app: Express, sessions: SessionManager, ctx: Measur
void withSession(req, res, 'tx', async (ctx, session, rateLimit) => {
const tx: any = (await retrieveJson(req)) ?? {}
if (tx._class === core.class.TxDomainEvent) {
const domainTx = tx as TxDomainEvent
const { result } = await session.domainRequestRaw(ctx, domainTx.domain, {
event: domainTx.event
})
await sendJson(req, res, result.value, rateLimitToHeaders(rateLimit))
} else {
const result = await session.txRaw(ctx, tx)
await sendJson(req, res, result.result, rateLimitToHeaders(rateLimit))
try {
if (tx._class === core.class.TxDomainEvent) {
const domainTx = tx as TxDomainEvent
const { result } = await session.domainRequestRaw(ctx, domainTx.domain, {
event: domainTx.event
})
await sendJson(req, res, result.value, rateLimitToHeaders(rateLimit))
} else {
const result = await session.txRaw(ctx, tx)
await sendJson(req, res, result.result, rateLimitToHeaders(rateLimit))
}
} catch (err: unknown) {
if (err instanceof PlatformError && err.status.code === platform.status.BadRequest) {
sendError(res, 400, {
message: 'Invalid tx',
error: err.status
})
return
}
throw err
}
})
})
+5
View File
@@ -2221,6 +2221,11 @@
"projectFolder": "services/notification/pod-notification",
"shouldPublish": false
},
{
"packageName": "@hcengineering/pod-events-processor",
"projectFolder": "services/notification/pod-events-processor",
"shouldPublish": false
},
{
"packageName": "@hcengineering/pod-telegram",
"projectFolder": "services/telegram/pod-telegram",
@@ -44,6 +44,128 @@ import { QueueTopic, TriggerControl } from '@hcengineering/server-core'
import { getHTMLPresenter, getTextPresenter } from '@hcengineering/server-notification-resources'
import { generateToken } from '@hcengineering/server-token'
const scheduledNotificationTopic = 'scheduledNotification'
interface ScheduledNotificationMessage {
kind: 'eventReminder'
id: string
eventId: Ref<Event>
eventClass: Ref<Class<Event>>
shiftMs: number
targetDate: number
}
type TimeMachineMessage =
| {
type: 'schedule'
id: string
targetDate: number
topic: string
data: ScheduledNotificationMessage
}
| {
type: 'cancel'
id: string
}
type TimeMachineScheduleMessage = Extract<TimeMachineMessage, { type: 'schedule' }>
function eventReminderPrefix (eventId: Ref<Event>): string {
return `eventReminder_${eventId}_`
}
function eventReminderTimerId (eventId: Ref<Event>, shiftMs: number): string {
// Stable so we can cancel by `${prefix}%`.
return `${eventReminderPrefix(eventId)}${shiftMs}`
}
async function cancelEventReminders (control: TriggerControl, eventId: Ref<Event>): Promise<void> {
try {
const queue = control.queue
if (queue === undefined) return
const producer = queue.getProducer<TimeMachineMessage>(control.ctx, QueueTopic.TimeMachine)
const cancelId = `${eventReminderPrefix(eventId)}%`
await producer.send(control.ctx, control.workspace.uuid, [{ type: 'cancel', id: cancelId }])
control.ctx.info('Queued event reminder cancel', {
queueTopic: QueueTopic.TimeMachine,
eventId,
timerIdPattern: cancelId
})
} catch (err) {
control.ctx.error('Failed to cancel Event reminders', { err, eventId })
}
}
async function scheduleEventReminders (control: TriggerControl, eventId: Ref<Event>): Promise<void> {
try {
const queue = control.queue
if (queue === undefined) return
const event = (await control.findAll(control.ctx, calendar.class.Event, { _id: eventId }, { limit: 1 }))[0]
if (event === undefined) return
// Reset existing timers for this Event on any relevant change.
await cancelEventReminders(control, eventId)
const reminders = event.reminders ?? []
if (reminders.length === 0) return
const now = Date.now()
const msgs: TimeMachineScheduleMessage[] = []
for (const shiftMs of reminders) {
if (typeof shiftMs !== 'number' || Number.isNaN(shiftMs)) continue
// `shiftMs` is the positive offset before the event (in ms), matching the convention used by
// ReminderPopup and pod-calendar Google export.
const targetDate = event.date - shiftMs
if (targetDate <= now) continue
const id = eventReminderTimerId(eventId, shiftMs)
const data: ScheduledNotificationMessage = {
kind: 'eventReminder',
id,
eventId,
eventClass: event._class,
shiftMs,
targetDate
}
msgs.push({
type: 'schedule',
id,
targetDate,
topic: scheduledNotificationTopic,
data
})
}
if (msgs.length === 0) {
control.ctx.info('Skipped event reminder scheduling', {
queueTopic: QueueTopic.TimeMachine,
eventId,
eventClass: event._class,
reminderCount: reminders.length,
reason: 'no-future-reminders'
})
return
}
const producer = queue.getProducer<TimeMachineMessage>(control.ctx, QueueTopic.TimeMachine)
await producer.send(control.ctx, control.workspace.uuid, msgs)
control.ctx.info('Queued event reminders', {
queueTopic: QueueTopic.TimeMachine,
eventId,
eventClass: event._class,
reminderCount: reminders.length,
enqueuedCount: msgs.length,
timerIds: msgs.map((msg) => msg.id),
targetDates: msgs.map((msg) => msg.targetDate)
})
} catch (err) {
control.ctx.error('Failed to schedule Event reminders', { err, eventId })
}
}
export { scheduleEventReminders, cancelEventReminders }
/**
* @public
*/
@@ -214,6 +336,10 @@ async function onEventUpdate (ctx: TxUpdateDoc<Event>, control: TriggerControl):
void sendEventToService(event, 'update', control)
}
void putEventToQueue(control, 'update', event, ctx.modifiedBy, ops)
// Reschedule reminders if the event start time or the reminder offsets changed.
if (ops.date !== undefined || ops.reminders !== undefined) {
void scheduleEventReminders(control, ctx.objectId)
}
if (event.access !== 'owner') return []
const events = await control.findAll(control.ctx, calendar.class.Event, { eventId: event.eventId })
const res: Tx[] = []
@@ -363,6 +489,8 @@ async function onEventCreate (ctx: TxCreateDoc<Event>, control: TriggerControl):
void sendEventToService(event, 'create', control)
}
void putEventToQueue(control, 'create', event, ctx.modifiedBy)
// Schedule reminders for any newly created event (including WorkSlots, since those are Events too).
void scheduleEventReminders(control, event._id)
if (event.access !== 'owner') return []
const res: Tx[] = []
const { _class, space, ...attr } = event
@@ -398,6 +526,7 @@ async function onEventCreate (ctx: TxCreateDoc<Event>, control: TriggerControl):
async function onRemoveEvent (ctx: TxRemoveDoc<Event>, control: TriggerControl): Promise<Tx[]> {
const removed = control.removedMap.get(ctx.objectId) as Event
const res: Tx[] = []
void cancelEventReminders(control, ctx.objectId)
if (removed !== undefined) {
if (ctx.modifiedBy !== core.account.System && removed.access === 'owner') {
void sendEventToService(removed, 'delete', control)
@@ -0,0 +1,243 @@
//
// 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.
//
/* eslint-disable @typescript-eslint/consistent-type-assertions */
import calendar, { type Event } from '@hcengineering/calendar'
import core, { generateId } from '@hcengineering/core'
import type { PlatformQueueProducer } from '@hcengineering/server-core'
import { cancelEventReminders, scheduleEventReminders } from './index'
type AnyProducer = PlatformQueueProducer<any>
function makeQueueMock (): { queue: { getProducer: jest.Mock }, send: jest.Mock } {
const send = jest.fn(async () => {})
const producer: AnyProducer = { send, close: async () => {}, getQueue: () => queue as any } as any
const queue = {
getProducer: jest.fn(() => producer)
}
return { queue, send }
}
function makeControl (overrides: Partial<any> = {}): { control: any, send: jest.Mock } {
const { queue, send } = makeQueueMock()
const control: any = {
ctx: {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
contextData: { account: { uuid: generateId(), primarySocialId: core.account.System } }
},
workspace: { uuid: generateId(), url: 'ws', dataId: 'ws' },
hierarchy: {
isDerived: jest.fn(() => true),
classHierarchyMixin: jest.fn(() => undefined)
},
modelDb: { findAll: jest.fn(), findAllSync: jest.fn(), getObject: jest.fn() },
removedMap: new Map(),
userStatusMap: new Map(),
queue,
cache: new Map(),
contextCache: new Map(),
storageAdapter: {} as any,
serviceAdaptersManager: {} as any,
lowLevel: {} as any,
txFactory: { createTxUpdateDoc: jest.fn(), createTxRemoveDoc: jest.fn(), createTxCollectionCUD: jest.fn() } as any,
apply: jest.fn(async () => ({})),
domainRequest: jest.fn(async () => ({})),
queryFind: jest.fn(async () => []),
txes: [],
findAll: jest.fn(async () => [])
}
Object.assign(control, overrides)
return { control, send }
}
describe('event reminder scheduling (TimeMachine)', () => {
const eventClass = calendar.class.Event
it('schedules reminders at `event.date - shiftMs` and uses the eventReminder_ timer prefix', async () => {
const eventId = generateId() as any
// 1 hour in the future, so a 5-minute "before" reminder is in the future too.
const eventDate = Date.now() + 60 * 60_000
const shiftMs = 5 * 60_000
const { control, send } = makeControl()
;(control.findAll as jest.Mock).mockImplementation(async (_ctx: any, _class: any, query: any) => {
if (_class === calendar.class.Event && query?._id === eventId) {
return [
{
_id: eventId,
_class: eventClass,
space: core.space.Workspace,
date: eventDate,
dueDate: eventDate + 60_000,
reminders: [shiftMs]
} as Partial<Event>
]
}
return []
})
await scheduleEventReminders(control, eventId)
// Producer.send signature: (ctx, workspace, msgs).
const msgs = send.mock.calls.map((c: any[]) => c[2]).flat()
const cancelMsg = msgs.find((m: any) => m.type === 'cancel')
const scheduleMsg = msgs.find((m: any) => m.type === 'schedule' && m.topic === 'scheduledNotification')
expect(cancelMsg).toBeDefined()
expect(cancelMsg.id).toBe(`eventReminder_${eventId}_%`)
expect(scheduleMsg).toBeDefined()
expect(scheduleMsg.id).toBe(`eventReminder_${eventId}_${shiftMs}`)
// Reminder must fire BEFORE the event, exactly `shiftMs` earlier.
expect(scheduleMsg.targetDate).toBe(eventDate - shiftMs)
expect(scheduleMsg.data.kind).toBe('eventReminder')
expect(scheduleMsg.data.eventId).toBe(eventId)
expect(scheduleMsg.data.eventClass).toBe(eventClass)
expect(scheduleMsg.data.shiftMs).toBe(shiftMs)
expect(scheduleMsg.data.targetDate).toBe(eventDate - shiftMs)
})
it('skips reminders that resolve to the past', async () => {
const eventId = generateId() as any
// Event 1 minute in the future, 5-min reminder lands 4 minutes in the past — must be skipped.
const eventDate = Date.now() + 60_000
const shiftMs = 5 * 60_000
const { control, send } = makeControl()
;(control.findAll as jest.Mock).mockImplementation(async (_ctx: any, _class: any, query: any) => {
if (_class === calendar.class.Event && query?._id === eventId) {
return [
{
_id: eventId,
_class: eventClass,
space: core.space.Workspace,
date: eventDate,
dueDate: eventDate + 60_000,
reminders: [shiftMs]
} as Partial<Event>
]
}
return []
})
await scheduleEventReminders(control, eventId)
const msgs = send.mock.calls.map((c: any[]) => c[2]).flat()
expect(msgs.find((m: any) => m.type === 'schedule')).toBeUndefined()
// Cancel for the prefix is still issued so any prior timers get cleared.
expect(msgs.find((m: any) => m.type === 'cancel')).toBeDefined()
})
it('does not schedule when the event has no reminders configured', async () => {
const eventId = generateId() as any
const { control, send } = makeControl()
;(control.findAll as jest.Mock).mockImplementation(async (_ctx: any, _class: any, query: any) => {
if (_class === calendar.class.Event && query?._id === eventId) {
return [
{
_id: eventId,
_class: eventClass,
space: core.space.Workspace,
date: Date.now() + 60 * 60_000,
dueDate: Date.now() + 70 * 60_000,
reminders: []
} as Partial<Event>
]
}
return []
})
await scheduleEventReminders(control, eventId)
const msgs = send.mock.calls.map((c: any[]) => c[2]).flat()
// Cancel always issued (resets prior state); but no schedule msg.
expect(msgs.find((m: any) => m.type === 'schedule')).toBeUndefined()
})
it('schedules ALL future reminders when more than one is configured', async () => {
const eventId = generateId() as any
const eventDate = Date.now() + 60 * 60_000
const shifts = [5 * 60_000, 15 * 60_000, 30 * 60_000]
const { control, send } = makeControl()
;(control.findAll as jest.Mock).mockImplementation(async (_ctx: any, _class: any, query: any) => {
if (_class === calendar.class.Event && query?._id === eventId) {
return [
{
_id: eventId,
_class: eventClass,
space: core.space.Workspace,
date: eventDate,
dueDate: eventDate + 60_000,
reminders: shifts
} as Partial<Event>
]
}
return []
})
await scheduleEventReminders(control, eventId)
const msgs = send.mock.calls.map((c: any[]) => c[2]).flat()
const scheduleMsgs = msgs.filter((m: any) => m.type === 'schedule')
expect(scheduleMsgs).toHaveLength(shifts.length)
for (const shiftMs of shifts) {
const m = scheduleMsgs.find((s: any) => s.id === `eventReminder_${eventId}_${shiftMs}`)
expect(m).toBeDefined()
expect(m.targetDate).toBe(eventDate - shiftMs)
}
})
it('cancelEventReminders sends a wildcard cancel for the prefix', async () => {
const eventId = generateId() as any
const { control, send } = makeControl()
await cancelEventReminders(control, eventId)
const msgs = send.mock.calls.map((c: any[]) => c[2]).flat()
const cancelMsg = msgs.find((m: any) => m.type === 'cancel')
expect(cancelMsg).toBeDefined()
expect(cancelMsg.id).toBe(`eventReminder_${eventId}_%`)
})
it('does nothing when control.queue is undefined', async () => {
const eventId = generateId() as any
const { control, send } = makeControl({ queue: undefined })
await scheduleEventReminders(control, eventId)
await cancelEventReminders(control, eventId)
expect(send).not.toHaveBeenCalled()
})
it('does nothing when the event is not found', async () => {
const eventId = generateId() as any
const { control, send } = makeControl()
;(control.findAll as jest.Mock).mockResolvedValue([])
await scheduleEventReminders(control, eventId)
// No event to schedule for — only the cancel-on-reset behavior is skipped too because we bail
// before that. Verify nothing was sent.
expect(send).not.toHaveBeenCalled()
})
})
+113 -1
View File
@@ -61,9 +61,79 @@ import { getMetadata, translate } from '@hcengineering/platform'
import { getEmployee, getPersonSpaces } from '@hcengineering/server-contact'
import serverCore, { TriggerControl } from '@hcengineering/server-core'
import setting from '@hcengineering/setting'
import view from '@hcengineering/view'
import view, { type BuildModelKey, type Viewlet } from '@hcengineering/view'
import { workbenchId } from '@hcengineering/workbench'
type ViewletConfigItem = BuildModelKey | string
interface IndexedViewletConfigItem {
item: ViewletConfigItem
index: number
}
function getViewletConfigKey (item: ViewletConfigItem): string {
return typeof item === 'string' ? item : item.key
}
function getAttributeKey (key: string): string {
if (key.startsWith('$lookup.')) {
return key.slice('$lookup.'.length)
}
const dotIndex = key.lastIndexOf('.')
return dotIndex === -1 ? key : key.slice(dotIndex + 1)
}
function isSourceAttribute (control: TriggerControl, sourceClass: Ref<Class<Doc>>, key: string): boolean {
return control.hierarchy.getAllAttributes(sourceClass).has(getAttributeKey(key))
}
function syncViewletConfigOrder (
control: TriggerControl,
sourceClass: Ref<Class<Doc>>,
previousSourceConfig: ViewletConfigItem[],
sourceConfig: ViewletConfigItem[],
targetConfig: ViewletConfigItem[]
): ViewletConfigItem[] {
const sourceKeys = new Set(sourceConfig.map(getViewletConfigKey))
const previousSourceKeys = new Set(previousSourceConfig.map(getViewletConfigKey))
const targetByKey = new Map<string, IndexedViewletConfigItem[]>()
for (const [index, item] of targetConfig.entries()) {
const key = getViewletConfigKey(item)
const items = targetByKey.get(key) ?? []
items.push({ item, index })
targetByKey.set(key, items)
}
const sourceItems: ViewletConfigItem[] = []
const usedIndexes = new Set<number>()
for (const sourceItem of sourceConfig) {
const key = getViewletConfigKey(sourceItem)
const targetItem = targetByKey.get(key)?.shift()
const item = targetItem?.item ?? sourceItem
sourceItems.push(item)
if (targetItem !== undefined) {
usedIndexes.add(targetItem.index)
}
}
const synced = [...sourceItems]
for (const [index, targetItem] of targetConfig.entries()) {
if (usedIndexes.has(index)) continue
const key = getViewletConfigKey(targetItem)
if (!sourceKeys.has(key) && (previousSourceKeys.has(key) || isSourceAttribute(control, sourceClass, key))) {
continue
}
synced.splice(Math.min(index, synced.length), 0, targetItem)
}
return synced
}
function isConfigOrderChanged (current: ViewletConfigItem[], next: ViewletConfigItem[]): boolean {
return current.length !== next.length || current.some((item, index) => item !== next[index])
}
async function OnAttribute (ctx: TxCreateDoc<AnyAttribute>[], control: TriggerControl): Promise<Tx[]> {
const attr = TxProcessor.createDoc2Doc(ctx[0])
if (control.hierarchy.isDerived(attr.attributeOf, card.class.Card)) {
@@ -159,6 +229,47 @@ async function OnAttributeRemove (ctx: TxRemoveDoc<AnyAttribute>[], control: Tri
return []
}
async function OnViewletUpdate (ctx: TxUpdateDoc<Viewlet>[], control: TriggerControl): Promise<Tx[]> {
const updateTx = ctx[0]
if (updateTx.space === core.space.DerivedTx) return []
if (!Array.isArray(updateTx.operations.config)) return []
const sourceViewlet = (await control.findAll<Viewlet>(control.ctx, view.class.Viewlet, { _id: updateTx.objectId }))[0]
if (sourceViewlet === undefined) return []
if (!control.hierarchy.isDerived(sourceViewlet.attachTo, card.class.Card)) return []
const descendants = control.hierarchy
.getDescendants(sourceViewlet.attachTo)
.filter((it) => it !== sourceViewlet.attachTo)
if (descendants.length === 0) return []
const childViewlets = await control.findAll<Viewlet>(control.ctx, view.class.Viewlet, {
attachTo: { $in: descendants },
descriptor: sourceViewlet.descriptor,
variant: sourceViewlet.variant ?? { $exists: false }
})
const res: Tx[] = []
for (const childViewlet of childViewlets) {
const config = syncViewletConfigOrder(
control,
sourceViewlet.attachTo,
sourceViewlet.config,
updateTx.operations.config,
childViewlet.config
)
if (!isConfigOrderChanged(childViewlet.config, config)) continue
res.push(
control.txFactory.createTxUpdateDoc(childViewlet._class, childViewlet.space, childViewlet._id, {
config
})
)
}
return res
}
async function OnMasterTagRemove (ctx: TxUpdateDoc<MasterTag>[], control: TriggerControl): Promise<Tx[]> {
const updateTx = ctx[0]
if (updateTx.space === core.space.DerivedTx) return []
@@ -937,6 +1048,7 @@ export default async () => ({
trigger: {
OnAttribute,
OnAttributeRemove,
OnViewletUpdate,
OnMasterTagCreate,
OnMasterTagRemove,
OnTagRemove,
+1
View File
@@ -37,6 +37,7 @@ export default plugin(serverCardId, {
trigger: {
OnAttribute: '' as Resource<TriggerFunc>,
OnAttributeRemove: '' as Resource<TriggerFunc>,
OnViewletUpdate: '' as Resource<TriggerFunc>,
OnMasterTagCreate: '' as Resource<TriggerFunc>,
OnTagRemove: '' as Resource<TriggerFunc>,
OnMasterTagRemove: '' as Resource<TriggerFunc>,
@@ -15,6 +15,7 @@
import serverCore, { TriggerControl } from '@hcengineering/server-core'
import serverNotification, { PUSH_NOTIFICATION_TITLE_SIZE } from '@hcengineering/server-notification'
import type { ReceiverInfo } from '@hcengineering/server-notification'
import {
AccountUuid,
Class,
@@ -49,6 +50,7 @@ import contact, {
} from '@hcengineering/contact'
import { AvailableProvidersCache, AvailableProvidersCacheKey, getTranslatedNotificationContent } from './index'
import { getPerson } from '@hcengineering/server-contact'
import { getAllowedProviders, getNotificationProviderControl, getReceiversInfo } from './utils'
async function createPushFromInbox (
control: TriggerControl,
@@ -235,25 +237,51 @@ export async function PushNotificationsHandler (
): Promise<Tx[]> {
const availableProviders: AvailableProvidersCache = control.contextCache.get(AvailableProvidersCacheKey) ?? new Map()
const all: InboxNotification[] = txes
.map((tx) => TxProcessor.createDoc2Doc(tx))
.filter(
(it) =>
availableProviders.get(it._id)?.find((p) => p === notification.providers.PushNotificationProvider) !== undefined
)
const all: InboxNotification[] = txes.map((tx) => TxProcessor.createDoc2Doc(tx))
if (all.length === 0) {
// First pass: use cache if present.
const pushEnabled: InboxNotification[] = all.filter(
(it) =>
availableProviders.get(it._id)?.find((p) => p === notification.providers.PushNotificationProvider) !== undefined
)
// Fallback: if cache doesn't have the provider info (e.g. scheduled notifications created outside tx-trigger paths),
// compute allowed providers from notification type + user settings.
if (pushEnabled.length < all.length) {
const notificationControl = await getNotificationProviderControl(control.ctx, control)
const receivers: ReceiverInfo[] = await getReceiversInfo(control.ctx, [...new Set(all.map((n) => n.user))], control)
const receiverByAccount = new Map(receivers.map((r) => [r.account, r]))
for (const n of all) {
if (availableProviders.get(n._id) !== undefined) continue
if (pushEnabled.includes(n)) continue
const type = (n.types ?? [])[0]
if (type === undefined) continue
const notificationType = control.modelDb.getObject(type)
const receiver = receiverByAccount.get(n.user)
if (receiver === undefined) continue
const allowedProviders = getAllowedProviders(control, receiver.socialIds, notificationType, notificationControl)
if (allowedProviders.includes(notification.providers.PushNotificationProvider)) {
pushEnabled.push(n)
}
}
}
if (pushEnabled.length === 0) {
return []
}
const receivers = new Set(all.map((it) => it.user))
const receivers = new Set(pushEnabled.map((it) => it.user))
const subscriptions = (await control.queryFind(control.ctx, notification.class.PushSubscription, {})).filter((it) =>
receivers.has(it.user)
)
const res: Tx[] = []
for (const inboxNotification of all) {
for (const inboxNotification of pushEnabled) {
const { user } = inboxNotification
const userSubscriptions = subscriptions.filter((it) => it.user === user)
@@ -804,7 +804,7 @@ export async function CreateToDo (
todoResults.push({
_id: generateId() as any as ContextId,
name: attr.name,
name: attr.label,
key: attr.name,
type: attr.type
})
@@ -0,0 +1,276 @@
//
// 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 {
type Class,
type Client,
type Doc,
type Hierarchy,
type MarkupBlobRef,
type MeasureContext,
type Ref,
type WorkspaceIds
} from '@hcengineering/core'
import { type StorageAdapter } from '@hcengineering/server-core'
import { Buffer } from 'buffer'
import { UnifiedConverter } from '../converter'
// isId requires a 24-character hex string
const refA = '6763a1b2c3d4e5f601234567' as Ref<Doc>
const refB = '6763a1b2c3d4e5f601234568' as Ref<Doc>
const targetClass = 'test:class:Target' as Ref<Class<Doc>>
const abstractClass = 'core:class:Doc' as Ref<Class<Doc>>
function createMockMeasureContext (): MeasureContext {
return {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
newChild: jest.fn(),
with: jest.fn(),
withSync: jest.fn(),
measure: jest.fn(),
end: jest.fn()
} as unknown as MeasureContext
}
function createMockHierarchy (): Hierarchy {
return {
getAllAttributes: jest.fn(() => new Map()),
isMixin: jest.fn(() => false),
hasMixin: jest.fn(() => false),
isDerived: jest.fn(() => true),
getBaseClass: jest.fn((c: any) => c),
findDomain: jest.fn(() => 'test-domain'),
getClass: jest.fn(),
findAttribute: jest.fn()
} as unknown as Hierarchy
}
function createMockClient (hierarchy: Hierarchy): Client {
return {
findAll: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
getHierarchy: jest.fn(() => hierarchy)
} as unknown as Client
}
function createMockStorageAdapter (): StorageAdapter {
return {
read: jest.fn(),
stat: jest.fn(),
put: jest.fn(),
remove: jest.fn(),
exists: jest.fn()
} as unknown as StorageAdapter
}
describe('UnifiedConverter', () => {
let context: MeasureContext
let hierarchy: Hierarchy
let client: Client
let storage: StorageAdapter
let wsIds: WorkspaceIds
let converter: UnifiedConverter
beforeEach(() => {
jest.clearAllMocks()
context = createMockMeasureContext()
hierarchy = createMockHierarchy()
client = createMockClient(hierarchy)
storage = createMockStorageAdapter()
wsIds = { uuid: 'test-ws' as any, url: 'ws://test' }
converter = new UnifiedConverter(context, client, storage, wsIds)
})
describe('resolveReference', () => {
it('should return raw ref for abstract classes without domain instead of querying', async () => {
;(hierarchy.findDomain as jest.Mock).mockReturnValue(undefined)
const result = await (converter as any).resolveReference(refA, abstractClass)
expect(result).toBe(refA)
// Must not attempt findAll: abstract classes have no domain and the query would fail
expect(client.findAll).not.toHaveBeenCalled()
expect(context.error).not.toHaveBeenCalled()
expect(context.warn).not.toHaveBeenCalled()
})
it('should resolve a reference to a meaningful identifier', async () => {
const doc = { _id: refA, _class: targetClass, identifier: 'TSK-1' }
;(client.findAll as jest.Mock)
.mockResolvedValueOnce([{ _id: refA }]) // ids query
.mockResolvedValueOnce([doc]) // batch query
const result = await (converter as any).resolveReference(refA, targetClass)
expect(result).toBe('TSK-1')
})
it('should warn only once per missing reference', async () => {
;(client.findAll as jest.Mock).mockResolvedValue([])
const first = await (converter as any).resolveReference(refA, targetClass)
const second = await (converter as any).resolveReference(refA, targetClass)
expect(first).toBe(refA)
expect(second).toBe(refA)
const warns = (context.warn as jest.Mock).mock.calls.filter((c) => String(c[0]).includes(refA))
expect(warns).toHaveLength(1)
})
it('should not retry cache loading after a failure and should log the failure once', async () => {
;(client.findAll as jest.Mock).mockRejectedValue(new Error('domain not found: core:class:Doc '))
const first = await (converter as any).resolveReference(refA, targetClass)
const second = await (converter as any).resolveReference(refB, targetClass)
// References are kept as-is, export continues
expect(first).toBe(refA)
expect(second).toBe(refB)
// The failed query must not be retried for every reference
expect(client.findAll).toHaveBeenCalledTimes(1)
// Cache load failure is logged once
const cacheErrors = (context.error as jest.Mock).mock.calls.filter((c) =>
String(c[0]).includes('Failed to load document cache')
)
expect(cacheErrors).toHaveLength(1)
// No 'Failed to resolve reference' spam per each reference
const refErrors = (context.error as jest.Mock).mock.calls.filter((c) =>
String(c[0]).includes('Failed to resolve reference')
)
expect(refErrors).toHaveLength(0)
})
it('should return non-id values as-is', async () => {
const result = await (converter as any).resolveReference('not-an-id', targetClass)
expect(result).toBe('not-an-id')
expect(client.findAll).not.toHaveBeenCalled()
})
})
describe('resolveMarkdown', () => {
const blobRef = 'blob-description-1' as MarkupBlobRef
it('should return markup content when blob exists', async () => {
;(storage.stat as jest.Mock).mockResolvedValue({ size: 4, contentType: 'application/json' })
;(storage.read as jest.Mock).mockResolvedValue([Buffer.from('test')])
const result = await (converter as any).resolveMarkdown(blobRef)
expect(result).toBe('test')
expect(context.warn).not.toHaveBeenCalled()
expect(context.error).not.toHaveBeenCalled()
})
it('should warn and return empty string when blob does not exist (stat undefined)', async () => {
;(storage.stat as jest.Mock).mockResolvedValue(undefined)
const result = await (converter as any).resolveMarkdown(blobRef)
expect(result).toBe('')
expect(storage.read).not.toHaveBeenCalled()
expect(context.warn).toHaveBeenCalledWith(expect.stringContaining(`Blob not found: ${blobRef}`))
expect(context.error).not.toHaveBeenCalled()
})
it('should treat "missing" read errors as warnings, not export errors', async () => {
;(storage.stat as jest.Mock).mockResolvedValue({ size: 4, contentType: 'application/json' })
;(storage.read as jest.Mock).mockRejectedValue(new Error(`uuid=x dataId=y missing ${blobRef}`))
const result = await (converter as any).resolveMarkdown(blobRef)
expect(result).toBe('')
expect(context.warn).toHaveBeenCalledWith(expect.stringContaining(`Blob content not found: ${blobRef}`))
expect(context.error).not.toHaveBeenCalled()
})
it('should warn only once per missing blob', async () => {
;(storage.stat as jest.Mock).mockResolvedValue(undefined)
await (converter as any).resolveMarkdown(blobRef)
await (converter as any).resolveMarkdown(blobRef)
const warns = (context.warn as jest.Mock).mock.calls.filter((c) => String(c[0]).includes(blobRef))
expect(warns).toHaveLength(1)
})
it('should log unexpected read errors as errors and return empty string', async () => {
;(storage.stat as jest.Mock).mockResolvedValue({ size: 4, contentType: 'application/json' })
;(storage.read as jest.Mock).mockRejectedValue(new Error('connection refused'))
const result = await (converter as any).resolveMarkdown(blobRef)
expect(result).toBe('')
expect(context.error).toHaveBeenCalledWith(
expect.stringContaining(`Failed to resolve markup content: ${blobRef}`),
expect.objectContaining({ error: 'connection refused' })
)
})
})
describe('resolveAttachments', () => {
const docId = refA
const docClass = 'test:class:Issue' as Ref<Class<Doc>>
function mockAttachmentCache (): void {
const att = {
_id: refB,
_class: 'attachment:class:Attachment' as Ref<Class<Doc>>,
attachedTo: docId,
attachedToClass: docClass,
collection: 'attachments',
name: 'file.txt',
size: 4,
type: 'text/plain',
file: 'blob-file-1'
}
;(client.findAll as jest.Mock)
.mockResolvedValueOnce([{ _id: att._id }]) // ids query
.mockResolvedValueOnce([att]) // batch query
}
it('should return empty buffer and warn when attachment read fails', async () => {
mockAttachmentCache()
;(storage.read as jest.Mock).mockRejectedValue(new Error('missing blob-file-1'))
const attachments = await (converter as any).resolveAttachments(docId, docClass)
expect(attachments).toHaveLength(1)
const data = await attachments[0].getData()
expect(data).toEqual(Buffer.from([]))
expect(context.warn).toHaveBeenCalledWith(
expect.stringContaining(`Failed to read attachment: ${refB}`),
expect.objectContaining({ error: 'missing blob-file-1' })
)
})
it('should return attachment data when read succeeds', async () => {
mockAttachmentCache()
;(storage.read as jest.Mock).mockResolvedValue([Buffer.from('test')])
const attachments = await (converter as any).resolveAttachments(docId, docClass)
const data = await attachments[0].getData()
expect(data).toEqual(Buffer.from('test'))
expect(context.warn).not.toHaveBeenCalled()
})
})
})
@@ -0,0 +1,49 @@
//
// 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 { sanitizeSpaceFileName } from '../exporter'
describe('sanitizeSpaceFileName', () => {
it('should keep ordinary names unchanged', () => {
expect(sanitizeSpaceFileName('DEV')).toBe('DEV')
expect(sanitizeSpaceFileName('Wellness Vault')).toBe('Wellness Vault')
})
it('should replace path separators to avoid nested directories', () => {
expect(sanitizeSpaceFileName('UI/UX Portfolio')).toBe('UI_UX Portfolio')
expect(sanitizeSpaceFileName('Ventas/Marketing')).toBe('Ventas_Marketing')
expect(sanitizeSpaceFileName('back\\slash')).toBe('back_slash')
})
it('should prevent path traversal', () => {
expect(sanitizeSpaceFileName('../../etc/passwd')).not.toContain('..')
expect(sanitizeSpaceFileName('../../etc/passwd')).not.toContain('/')
expect(sanitizeSpaceFileName('..')).toBe('_')
})
it('should strip control characters and reserved characters', () => {
expect(sanitizeSpaceFileName('name with\u0007control')).toBe('name withcontrol')
expect(sanitizeSpaceFileName('a:b*c?d"e<f>g|h')).toBe('a_b_c_d_e_f_g_h')
})
it('should not produce hidden files from leading dots', () => {
expect(sanitizeSpaceFileName('.hidden')).toBe('hidden')
})
it('should fall back to a placeholder for empty results', () => {
expect(sanitizeSpaceFileName('')).toBe('unnamed')
expect(sanitizeSpaceFileName('\u0000\u0001')).toBe('unnamed')
})
})
@@ -127,7 +127,8 @@ describe('RelationExporter sourceClass', () => {
hierarchy,
lowLevel,
expect.any(Map),
relations
relations,
false
)
})
@@ -178,7 +179,8 @@ describe('RelationExporter sourceClass', () => {
hierarchy,
lowLevel,
expect.any(Map),
relations
relations,
false
)
})
@@ -219,7 +221,8 @@ describe('RelationExporter sourceClass', () => {
hierarchy,
lowLevel,
expect.any(Map),
relations
relations,
false
)
})
@@ -541,6 +541,149 @@ describe('CrossWorkspaceExporter', () => {
expect(attachedToArg).not.toBe(SOURCE_DOC_1)
}
})
it('should NOT export child collection documents when includeChildren is false', async () => {
const sourceSpace = createMockSpace(SOURCE_SPACE_ID, 'Test Space')
const parentDoc = createMockDoc(SOURCE_DOC_1, mockDocClass, SOURCE_SPACE_ID)
const attachedDoc = createMockAttachedDoc(
SOURCE_ATTACHED_1,
mockAttachedDocClass,
SOURCE_SPACE_ID,
SOURCE_DOC_1,
mockDocClass,
'children',
{ title: 'Attached Doc' }
)
const hierarchy = createMockHierarchy({
domains: new Map([
[mockDocClass, 'test_domain'],
[mockAttachedDocClass, 'test_domain'],
[core.class.Space, 'space_domain']
]),
isDerived: new Map([[mockAttachedDocClass, true]]),
attributes: new Map([
[
mockDocClass,
new Map([['children', { type: { _class: core.class.Collection, of: mockAttachedDocClass } }]])
],
[
mockAttachedDocClass,
new Map([
['attachedTo', { type: { _class: core.class.RefTo } }],
['attachedToClass', { type: { _class: 'core:class:TypeString' as Ref<Class<Doc>> } }],
['collection', { type: { _class: 'core:class:TypeString' as Ref<Class<Doc>> } }]
])
]
])
})
const lowLevelStorage = createMockLowLevelStorage(
new Map([
['test_domain', [parentDoc, attachedDoc]],
['space_domain', [sourceSpace]]
])
)
const targetClient = createMockTxOperations([], [], hierarchy)
const pipelineFactory = createMockPipelineFactory(hierarchy, lowLevelStorage)
const exporter = new CrossWorkspaceExporter(
mockContext,
pipelineFactory,
targetClient,
mockStorage,
undefined,
createWorkspaceIds('source-ws'),
createWorkspaceIds('target-ws')
)
// Query the parent class only — and explicitly opt OUT of child export.
const result = await exporter.export({
sourceWorkspace: createWorkspaceIds('source-ws'),
targetWorkspace: createWorkspaceIds('target-ws'),
sourceQuery: { _id: SOURCE_DOC_1 } as any,
_class: mockDocClass,
includeChildren: false
})
expect(result.success).toBe(true)
// The parent doc must be created via createDoc, but the attached collection
// item must NOT go through addCollection because includeChildren is false.
// eslint-disable-next-line @typescript-eslint/unbound-method
expect((targetClient.addCollection as jest.Mock).mock.calls.length).toBe(0)
})
it('should export child collection documents when includeChildren is true', async () => {
const sourceSpace = createMockSpace(SOURCE_SPACE_ID, 'Test Space')
const parentDoc = createMockDoc(SOURCE_DOC_1, mockDocClass, SOURCE_SPACE_ID)
const attachedDoc = createMockAttachedDoc(
SOURCE_ATTACHED_1,
mockAttachedDocClass,
SOURCE_SPACE_ID,
SOURCE_DOC_1,
mockDocClass,
'children',
{ title: 'Attached Doc' }
)
const hierarchy = createMockHierarchy({
domains: new Map([
[mockDocClass, 'test_domain'],
[mockAttachedDocClass, 'test_domain'],
[core.class.Space, 'space_domain']
]),
isDerived: new Map([[mockAttachedDocClass, true]]),
attributes: new Map([
[
mockDocClass,
new Map([['children', { type: { _class: core.class.Collection, of: mockAttachedDocClass } }]])
],
[
mockAttachedDocClass,
new Map([
['attachedTo', { type: { _class: core.class.RefTo } }],
['attachedToClass', { type: { _class: 'core:class:TypeString' as Ref<Class<Doc>> } }],
['collection', { type: { _class: 'core:class:TypeString' as Ref<Class<Doc>> } }]
])
]
])
})
const lowLevelStorage = createMockLowLevelStorage(
new Map([
['test_domain', [parentDoc, attachedDoc]],
['space_domain', [sourceSpace]]
])
)
const targetClient = createMockTxOperations([], [], hierarchy)
const pipelineFactory = createMockPipelineFactory(hierarchy, lowLevelStorage)
const exporter = new CrossWorkspaceExporter(
mockContext,
pipelineFactory,
targetClient,
mockStorage,
undefined,
createWorkspaceIds('source-ws'),
createWorkspaceIds('target-ws')
)
// Query the parent class only — and opt IN to recursive child export.
const result = await exporter.export({
sourceWorkspace: createWorkspaceIds('source-ws'),
targetWorkspace: createWorkspaceIds('target-ws'),
sourceQuery: { _id: SOURCE_DOC_1 } as any,
_class: mockDocClass,
includeChildren: true
})
expect(result.success).toBe(true)
// With the flag on, the attached collection item must be created via addCollection.
// eslint-disable-next-line @typescript-eslint/unbound-method
expect((targetClient.addCollection as jest.Mock).mock.calls.length).toBeGreaterThan(0)
})
})
describe('Forward Relations', () => {
+59 -11
View File
@@ -46,6 +46,10 @@ export class UnifiedConverter {
// Fields that should not be resolved
private readonly skipResolveFields = new Set(['_class', '_id', 'collection', 'attachedTo', 'attachedToClass'])
// Deduplication sets to avoid flooding logs with repeated warnings
private readonly reportedMissingRefs = new Set<string>()
private readonly reportedMissingBlobs = new Set<string>()
constructor (
private readonly context: MeasureContext,
private readonly client: Client,
@@ -264,12 +268,21 @@ export class UnifiedConverter {
private async resolveReference (ref: Ref<Doc>, to: Ref<Class<Doc>>): Promise<string> {
if (!isId(ref)) return ref
// References to abstract classes (e.g. core:class:Doc) cannot be resolved
// via findAll since they have no associated domain, keep the raw identifier
if (this.client.getHierarchy().findDomain(to) === undefined) {
return ref
}
try {
const { byId } = await this.getCache(to)
const doc = byId.get(ref)
if (doc === undefined) {
this.context.warn(`Referenced document not found: ${ref}`)
if (!this.reportedMissingRefs.has(ref)) {
this.reportedMissingRefs.add(ref)
this.context.warn(`Referenced document not found: ${ref}`)
}
return ref
}
@@ -290,7 +303,15 @@ export class UnifiedConverter {
p = await p
}
if (p === undefined) {
p = this.loadCache(to)
p = this.loadCache(to).catch((err) => {
// Cache an empty result on failure, otherwise every reference
// to this class retries the query and floods logs with errors
this.context.error(`Failed to load document cache for ${to}`, {
error: err instanceof Error ? err.message : String(err)
})
const empty: DocCache = { byId: new Map(), byAttached: new Map() }
return empty
})
this.documentCache.set(to, p)
p = await p
this.documentCache.set(to, p)
@@ -324,6 +345,17 @@ export class UnifiedConverter {
private async resolveMarkdown (blobRef: MarkupBlobRef): Promise<string> {
try {
// Check blob existence first: storage adapters throw on missing blobs,
// and a missing description blob should not be treated as an export error
const stat = await this.storage.stat(this.context, this.wsIds, blobRef)
if (stat === undefined) {
if (!this.reportedMissingBlobs.has(blobRef)) {
this.reportedMissingBlobs.add(blobRef)
this.context.warn(`Blob not found: ${blobRef}`)
}
return ''
}
const buffer = await this.storage.read(this.context, this.wsIds, blobRef)
if (buffer === undefined) {
this.context.warn(`Blob not found: ${blobRef}`)
@@ -334,10 +366,19 @@ export class UnifiedConverter {
// const markdown = await markupToMarkdown(markup, '', '')
return markup // todo: test it is a markdown
} catch (err) {
this.context.error(`Failed to resolve markup content: ${blobRef}`, {
error: err instanceof Error ? err.message : String(err),
blobRef
})
const message = err instanceof Error ? err.message : String(err)
// Missing blob content is a data consistency issue, not an export failure
if (message.includes('missing')) {
if (!this.reportedMissingBlobs.has(blobRef)) {
this.reportedMissingBlobs.add(blobRef)
this.context.warn(`Blob content not found: ${blobRef}`)
}
} else {
this.context.error(`Failed to resolve markup content: ${blobRef}`, {
error: message,
blobRef
})
}
return ''
}
}
@@ -371,14 +412,21 @@ export class UnifiedConverter {
size: att.size,
contentType: att.type,
getData: async () => {
const buffer = await this.storage.read(this.context, this.wsIds, att.file)
try {
const buffer = await this.storage.read(this.context, this.wsIds, att.file)
if (buffer === undefined) {
this.context.warn(`Attachment not found: ${att._id}`)
if (buffer === undefined) {
this.context.warn(`Attachment not found: ${att._id}`)
return Buffer.from([])
}
return Buffer.concat(buffer as any)
} catch (err) {
this.context.warn(`Failed to read attachment: ${att._id}`, {
error: err instanceof Error ? err.message : String(err)
})
return Buffer.from([])
}
return Buffer.concat(buffer as any)
}
})
)
+18 -1
View File
@@ -44,6 +44,17 @@ export interface ExportOptions {
query?: DocumentQuery<Doc>
}
export function sanitizeSpaceFileName (name: string): string {
const sanitized = name
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f]/g, '')
.replace(/[/\\:*?"<>|]/g, '_')
.replace(/\.{2,}/g, '_')
.trim()
.replace(/^\.+/, '')
return sanitized.length > 0 ? sanitized : 'unnamed'
}
export class WorkspaceExporter {
private readonly jsonSerializer: UnifiedJsonSerializer
private readonly csvSerializer: UnifiedCsvSerializer
@@ -81,6 +92,7 @@ export class WorkspaceExporter {
)
const limiter = new RateLimiter(50)
const usedSpaceNames = new Set<string>()
// Process each space
for (const [spaceId, spaceDocs] of docsBySpace) {
const space = allSpaces.get(spaceId)
@@ -89,7 +101,12 @@ export class WorkspaceExporter {
continue
}
const spaceName = space.name ?? spaceId
let spaceName = sanitizeSpaceFileName(space.name ?? spaceId)
if (usedSpaceNames.has(spaceName)) {
// Different spaces may share the same (sanitized) name, avoid overwriting
spaceName = `${spaceName}-${spaceId}`
}
usedSpaceNames.add(spaceName)
const spaceDir = path.join(outputDir, spaceName)
// Convert all docs to UnifiedDoc format
+41 -12
View File
@@ -67,7 +67,7 @@ import archiver from 'archiver'
import { sendExportCompletionNotification } from './notifications'
import cors from 'cors'
import express, { type Express, type NextFunction, type Request, type Response } from 'express'
import { createWriteStream } from 'fs'
import { createReadStream, createWriteStream } from 'fs'
import fs from 'fs/promises'
import { IncomingHttpHeaders, type Server } from 'http'
import { tmpdir } from 'os'
@@ -254,6 +254,16 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n
handleRequest(fn, req, res, next)
}
// Only formats actually supported by WorkspaceExporter
const supportedExportFormats: readonly ExportFormat[] = [ExportFormat.JSON, ExportFormat.CSV]
function parseExportFormat (rawFormat: unknown): ExportFormat {
if (typeof rawFormat !== 'string' || !supportedExportFormats.includes(rawFormat as ExportFormat)) {
throw new ApiError(400, `Invalid format. Supported formats: ${supportedExportFormats.join(', ')}`)
}
return rawFormat as ExportFormat
}
export function createServer (
storageConfig: StorageConfiguration,
dbUrl: string,
@@ -269,7 +279,7 @@ export function createServer (
app.post(
'/exportAsync',
wrapRequest(async (req, res, wsIds, token, socialId) => {
const format = req.query.format as ExportFormat
const format = parseExportFormat(req.query.format)
const {
_class,
@@ -281,7 +291,7 @@ export function createServer (
attributesOnly: boolean
} = req.body
if (_class == null || format == null) {
if (_class == null) {
throw new ApiError(400, 'Missing required parameters')
}
@@ -343,9 +353,23 @@ export function createServer (
await sendSuccessNotification(txOperations, account, exportDrive, archiveName)
} catch (err: any) {
measureCtx.error('Export failed:', err)
await sendFailureNotification(txOperations, account, err.message ?? 'Unknown error when exporting')
try {
// Attach the failure notification to the export drive, otherwise
// the user is never notified that the export has failed
const exportDrive = await ensureExportDrive(txOperations, account)
await sendFailureNotification(
txOperations,
account,
err.message ?? 'Unknown error when exporting',
drive.class.Drive,
exportDrive,
core.space.Space
)
} catch (notifyErr: any) {
measureCtx.error('Failed to send export failure notification:', notifyErr)
}
} finally {
await fs.rmdir(exportDir, { recursive: true })
await fs.rm(exportDir, { recursive: true, force: true })
}
})()
})
@@ -354,7 +378,7 @@ export function createServer (
app.post(
'/exportSync',
wrapRequest(async (req, res, wsIds, token, socialId) => {
const format = req.query.format as ExportFormat
const format = parseExportFormat(req.query.format)
const {
_class,
query,
@@ -367,7 +391,7 @@ export function createServer (
config?: TransformConfig
} = req.body
if (_class == null || format == null) {
if (_class == null) {
throw new ApiError(400, 'Missing required parameters')
}
@@ -394,7 +418,7 @@ export function createServer (
measureCtx.error('Export failed:', err)
throw err
} finally {
void fs.rmdir(exportDir, { recursive: true })
void fs.rm(exportDir, { recursive: true, force: true })
}
})
)
@@ -414,7 +438,8 @@ export function createServer (
relations: rawRelations,
fieldMappers,
skipDeletedObsolete,
exportOnlyEffective
exportOnlyEffective,
includeChildren
}: {
targetWorkspace: WorkspaceUuid
_class: Ref<Class<Doc>>
@@ -427,6 +452,7 @@ export function createServer (
fieldMappers?: Record<string, Record<string, any>>
skipDeletedObsolete?: boolean
exportOnlyEffective?: boolean
includeChildren?: boolean
} = req.body
// Validate required parameters
@@ -543,6 +569,7 @@ export function createServer (
fieldMappers,
skipDeletedObsolete: skipDeletedObsolete ?? true,
exportOnlyEffective: exportOnlyEffective ?? false,
includeChildren: includeChildren ?? false,
customHandlers: [createProductVersionHandler()]
}
@@ -652,14 +679,16 @@ async function saveToDrive (
): Promise<Ref<Drive>> {
const exportDrive = await ensureExportDrive(client, account)
const fileContent = await fs.readFile(archivePath)
// Stream the archive instead of reading it into memory:
// fs.readFile fails with ERR_FS_FILE_TOO_LARGE for archives larger than 2 GiB
const { size } = await fs.stat(archivePath)
const blobId = uuid() as Ref<Blob>
await storage.put(ctx, wsIds, blobId, fileContent, 'application/zip', fileContent.length)
await storage.put(ctx, wsIds, blobId, createReadStream(archivePath), 'application/zip', size)
await createFile(client, exportDrive, drive.ids.Root, {
title: basename(archivePath),
file: blobId,
size: fileContent.length,
size,
type: 'application/zip',
lastModified: Date.now()
})
@@ -75,7 +75,8 @@ export class DocumentExporter {
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage,
existingDocsMap: Map<Ref<Doc>, Doc>,
relations: RelationDefinition[]
relations: RelationDefinition[],
includeChildren: boolean = false
): Promise<boolean> {
if (this.state.processingDocs.has(doc._id)) {
return false
@@ -127,7 +128,8 @@ export class DocumentExporter {
conflictStrategy,
includeAttachments,
sourceHierarchy,
sourceLowLevel
sourceLowLevel,
includeChildren
)
// Create the document
@@ -145,7 +147,8 @@ export class DocumentExporter {
conflictStrategy,
includeAttachments,
sourceHierarchy,
sourceLowLevel
sourceLowLevel,
includeChildren
)
await this.exportSpaceRelations(
doc,
@@ -154,7 +157,8 @@ export class DocumentExporter {
includeAttachments,
sourceHierarchy,
sourceLowLevel,
relations
relations,
includeChildren
)
// Handle attachments
@@ -165,8 +169,12 @@ export class DocumentExporter {
// Handle collaborative content blobs (e.g., document content)
await this.attachmentExporter.exportCollaborativeContent(doc, sourceHierarchy)
// Handle collections (child documents)
await this.exportCollections(doc, targetId, sourceHierarchy, sourceLowLevel, relations)
// Handle collections (child documents) — only when explicitly requested.
// When disabled, only the top-level documents matched by the export query
// are exported and their attached collection items are skipped.
if (includeChildren) {
await this.exportCollections(doc, targetId, sourceHierarchy, sourceLowLevel, relations, includeChildren)
}
return true
} catch (err: any) {
@@ -230,7 +238,8 @@ export class DocumentExporter {
includeAttachments: boolean,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage,
relations: RelationDefinition[]
relations: RelationDefinition[],
includeChildren: boolean
): Promise<void> {
try {
if (this.relationExporter === undefined) {
@@ -256,7 +265,8 @@ export class DocumentExporter {
conflictStrategy,
includeAttachments,
sourceHierarchy,
sourceLowLevel
sourceLowLevel,
includeChildren
)
} catch (err: any) {
this.context.error(`Failed to export relations for space ${space}:`, {
@@ -310,7 +320,8 @@ export class DocumentExporter {
targetDocId: Ref<Doc>,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage,
relations: RelationDefinition[]
relations: RelationDefinition[],
includeChildren: boolean
): Promise<void> {
const attributes = sourceHierarchy.getAllAttributes(sourceDoc._class)
@@ -349,7 +360,8 @@ export class DocumentExporter {
sourceHierarchy,
sourceLowLevel,
new Map(),
relations
relations,
includeChildren
)
} catch (err: any) {
this.context.error(`Failed to export collection item ${collectionDoc._id}:`, {
@@ -34,7 +34,8 @@ export type ExportDocumentFn = (
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage,
existingDocsMap: Map<Ref<Doc>, Doc>,
relations: RelationDefinition[]
relations: RelationDefinition[],
includeChildren: boolean
) => Promise<boolean>
/**
@@ -56,7 +57,8 @@ export class RelationExporter {
conflictStrategy: 'skip' | 'duplicate',
includeAttachments: boolean,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage
sourceLowLevel: LowLevelStorage,
includeChildren: boolean = false
): Promise<void> {
for (const relation of relations) {
const direction = relation.direction ?? 'forward'
@@ -71,7 +73,8 @@ export class RelationExporter {
includeAttachments,
sourceHierarchy,
sourceLowLevel,
relations
relations,
includeChildren
)
} catch (err: any) {
this.context.error(`Failed to export forward relation ${relation.field} for document ${doc._id}:`, {
@@ -92,7 +95,8 @@ export class RelationExporter {
conflictStrategy: 'skip' | 'duplicate',
includeAttachments: boolean,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage
sourceLowLevel: LowLevelStorage,
includeChildren: boolean = false
): Promise<void> {
for (const relation of relations) {
const direction = relation.direction ?? 'forward'
@@ -107,7 +111,8 @@ export class RelationExporter {
includeAttachments,
sourceHierarchy,
sourceLowLevel,
relations
relations,
includeChildren
)
} catch (err: any) {
this.context.error(`Failed to export inverse relation ${relation.field} for document ${doc._id}:`, {
@@ -125,7 +130,8 @@ export class RelationExporter {
conflictStrategy: 'skip' | 'duplicate',
includeAttachments: boolean,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage
sourceLowLevel: LowLevelStorage,
includeChildren: boolean = false
): Promise<void> {
await this.exportForwardRelations(
doc,
@@ -133,7 +139,8 @@ export class RelationExporter {
conflictStrategy,
includeAttachments,
sourceHierarchy,
sourceLowLevel
sourceLowLevel,
includeChildren
)
await this.exportInverseRelations(
doc,
@@ -141,7 +148,8 @@ export class RelationExporter {
conflictStrategy,
includeAttachments,
sourceHierarchy,
sourceLowLevel
sourceLowLevel,
includeChildren
)
}
@@ -152,7 +160,8 @@ export class RelationExporter {
includeAttachments: boolean,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage,
relations: RelationDefinition[]
relations: RelationDefinition[],
includeChildren: boolean
): Promise<void> {
if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) {
return
@@ -185,7 +194,8 @@ export class RelationExporter {
includeAttachments,
sourceHierarchy,
sourceLowLevel,
relations
relations,
includeChildren
)
}
}
@@ -197,7 +207,8 @@ export class RelationExporter {
includeAttachments: boolean,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage,
relations: RelationDefinition[]
relations: RelationDefinition[],
includeChildren: boolean
): Promise<void> {
if (relation.sourceClass !== undefined && !sourceHierarchy.isDerived(doc._class, relation.sourceClass)) {
return
@@ -232,7 +243,8 @@ export class RelationExporter {
sourceHierarchy,
sourceLowLevel,
new Map(),
relations
relations,
includeChildren
)
}
}
@@ -244,7 +256,8 @@ export class RelationExporter {
includeAttachments: boolean,
sourceHierarchy: Hierarchy,
sourceLowLevel: LowLevelStorage,
relations: RelationDefinition[]
relations: RelationDefinition[],
includeChildren: boolean
): Promise<void> {
if (this.state.idMapping.has(ref)) {
return
@@ -274,7 +287,8 @@ export class RelationExporter {
sourceHierarchy,
sourceLowLevel,
new Map(),
relations
relations,
includeChildren
)
}
}
@@ -84,6 +84,10 @@ export interface ExportOptions {
skipDeletedObsolete?: boolean
// Whether to export only documents with effective status
exportOnlyEffective?: boolean
// Whether to recursively export child (collection) documents of each exported doc.
// When false, only the documents matched by the top-level query are exported and
// their attached collection items are skipped.
includeChildren?: boolean
// Class-specific export handlers, applied before the default flow. Useful
// for collapsing or deduplicating documents (e.g. ProductVersion).
customHandlers?: CustomExportHandler[]

Some files were not shown because too many files have changed in this diff Show More