mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
+2
-1
@@ -113,4 +113,5 @@ dev/tool/history.json
|
||||
/combined_dependencies
|
||||
.tmp
|
||||
ws-tests/docker-compose.override.yml
|
||||
.cursor/*
|
||||
.cursor/*
|
||||
CLAUDE.md
|
||||
@@ -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:
|
||||
|
||||
Generated
+112
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
])
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"ExportFilterEffectiveOnly": "有効なドキュメントのみ",
|
||||
"ExportFilterSkipArchivedObsolete": "アーカイブ・廃止を除くすべて",
|
||||
"ExportFilterAll": "すべてのドキュメント",
|
||||
"ExportChildDocuments": "子ドキュメントをエクスポート",
|
||||
"ExportChildDocumentsDescription": "選択したドキュメントに添付されたドキュメントもエクスポートします",
|
||||
"ExportResultRecordTitle": "ワークスペース {workspace} からのドキュメントのエクスポート ({date})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"ExportFilterEffectiveOnly": "유효한 문서만",
|
||||
"ExportFilterSkipArchivedObsolete": "보관/폐기 제외 전체",
|
||||
"ExportFilterAll": "모든 문서",
|
||||
"ExportChildDocuments": "하위 문서 내보내기",
|
||||
"ExportChildDocumentsDescription": "선택한 문서에 첨부된 문서도 함께 내보냅니다",
|
||||
"ExportResultRecordTitle": "{workspace} 워크스페이스 문서 내보내기 ({date})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"ExportFilterEffectiveOnly": "Только действующие документы",
|
||||
"ExportFilterSkipArchivedObsolete": "Все кроме архивных и устаревших",
|
||||
"ExportFilterAll": "Все документы",
|
||||
"ExportChildDocuments": "Экспортировать дочерние документы",
|
||||
"ExportChildDocumentsDescription": "Также экспортировать документы, прикреплённые к выбранным",
|
||||
"ExportResultRecordTitle": "Экспорт документов из пространства {workspace} ({date})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
"NoAssignee": "担当者なし",
|
||||
"LastUpdated": "最終更新日",
|
||||
"DueDate": "期日",
|
||||
"IssueStartDate": "開始日",
|
||||
"GanttDependency": "Dependency",
|
||||
"GanttLag": "Lag",
|
||||
"Manual": "手動",
|
||||
"All": "すべて",
|
||||
"PastWeek": "先週",
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
"NoAssignee": "담당자 없음",
|
||||
"LastUpdated": "최근 업데이트",
|
||||
"DueDate": "마감일",
|
||||
"IssueStartDate": "시작일",
|
||||
"GanttDependency": "Dependency",
|
||||
"GanttLag": "Lag",
|
||||
"Manual": "수동",
|
||||
"All": "전체",
|
||||
"PastWeek": "지난주",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -125,6 +125,9 @@
|
||||
"NoAssignee": "Нет исполнителя",
|
||||
"LastUpdated": "Последнее обновление",
|
||||
"DueDate": "Срок",
|
||||
"IssueStartDate": "Дата начала",
|
||||
"GanttDependency": "Dependency",
|
||||
"GanttLag": "Lag",
|
||||
"Manual": "Пользовательский",
|
||||
"All": "Все",
|
||||
"PastWeek": "Предыдущая неделя",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user