mirror of
https://github.com/hcengineering/platform.git
synced 2025-05-25 01:15:14 +00:00
Merge branch 'develop' into staging-new
Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
commit
8c09674c53
@ -13,7 +13,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { cardId, DOMAIN_CARD } from '@hcengineering/card'
|
||||
import { type Card, cardId, DOMAIN_CARD } from '@hcengineering/card'
|
||||
import core, { TxOperations, type Client, type Data, type Doc } from '@hcengineering/core'
|
||||
import {
|
||||
tryMigrate,
|
||||
@ -39,6 +39,11 @@ export const cardOperation: MigrateOperation = {
|
||||
state: 'migrate-spaces',
|
||||
mode: 'upgrade',
|
||||
func: migrateSpaces
|
||||
},
|
||||
{
|
||||
state: 'migrate-childs-spaces',
|
||||
mode: 'upgrade',
|
||||
func: migrateChildsSpaces
|
||||
}
|
||||
])
|
||||
},
|
||||
@ -175,6 +180,14 @@ async function createDefaultProject (tx: TxOperations): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateChildsSpaces (client: MigrationClient): Promise<void> {
|
||||
const toUpdate = await client.find<Card>(DOMAIN_CARD, { space: core.space.Workspace })
|
||||
for (const doc of toUpdate) {
|
||||
const parent = doc.parent != null ? (await client.find(DOMAIN_CARD, { _id: doc.parent }))[0] : undefined
|
||||
await client.update(DOMAIN_CARD, { _id: doc._id }, { space: parent?.space ?? card.space.Default })
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateSpaces (client: MigrationClient): Promise<void> {
|
||||
await client.update(DOMAIN_CARD, { space: core.space.Workspace }, { space: card.space.Default })
|
||||
}
|
||||
|
@ -16,40 +16,43 @@
|
||||
import { saveCollabJson } from '@hcengineering/collaboration'
|
||||
import core, {
|
||||
buildSocialIdString,
|
||||
configUserAccountUuid,
|
||||
coreId,
|
||||
DOMAIN_MODEL_TX,
|
||||
DOMAIN_SPACE,
|
||||
DOMAIN_STATUS,
|
||||
DOMAIN_TX,
|
||||
generateId,
|
||||
groupByArray,
|
||||
makeCollabJsonId,
|
||||
makeCollabYdocId,
|
||||
makeDocCollabId,
|
||||
MeasureMetricsContext,
|
||||
RateLimiter,
|
||||
SocialIdType,
|
||||
type PersonId,
|
||||
systemAccountUuid,
|
||||
toIdMap,
|
||||
TxProcessor,
|
||||
type AccountUuid,
|
||||
type AnyAttribute,
|
||||
type AttachedDoc,
|
||||
type Blob,
|
||||
type Class,
|
||||
type Doc,
|
||||
type Domain,
|
||||
type MeasureContext,
|
||||
type PersonId,
|
||||
type Ref,
|
||||
type Role,
|
||||
type SocialKey,
|
||||
type Space,
|
||||
type SpaceType,
|
||||
type Status,
|
||||
type TxCreateDoc,
|
||||
type TxCUD,
|
||||
type SpaceType,
|
||||
type TxMixin,
|
||||
type TxUpdateDoc,
|
||||
type Role,
|
||||
toIdMap,
|
||||
type TypedSpace,
|
||||
TxProcessor,
|
||||
type SocialKey,
|
||||
type AccountUuid,
|
||||
systemAccountUuid,
|
||||
configUserAccountUuid
|
||||
type TypedSpace
|
||||
} from '@hcengineering/core'
|
||||
import {
|
||||
createDefaultSpace,
|
||||
@ -264,6 +267,41 @@ async function processMigrateContentFor (
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateBackupMixins (client: MigrationClient): Promise<void> {
|
||||
// Go via classes with domain and check if mixin exists and need to flush %hash%
|
||||
const hierarchy = client.hierarchy
|
||||
const curHash = Date.now().toString(16) // Current hash value
|
||||
|
||||
const txIterator = await client.traverse<TxMixin<Doc, AttachedDoc>>(DOMAIN_TX, { _class: core.class.TxMixin })
|
||||
|
||||
while (true) {
|
||||
const mixinOps = await txIterator.next(500)
|
||||
if (mixinOps === null || mixinOps.length === 0) break
|
||||
const _classes = groupByArray(mixinOps, (it) => it.objectClass)
|
||||
|
||||
for (const [_class, ops] of _classes.entries()) {
|
||||
const domain = hierarchy.findDomain(_class)
|
||||
if (domain === undefined) continue
|
||||
let docs = await client.find(domain, { _id: { $in: ops.map((it) => it.objectId) } })
|
||||
|
||||
docs = docs.filter((it) => {
|
||||
// Check if mixin is last operation by modifiedOn
|
||||
const mops = ops.filter((mi) => mi.objectId === it._id)
|
||||
if (mops.length === 0) return false
|
||||
return mops.some((mi) => mi.modifiedOn === it.modifiedOn && mi.modifiedBy === it.modifiedBy)
|
||||
})
|
||||
|
||||
if (docs.length > 0) {
|
||||
// Check if docs has mixins from list
|
||||
const toUpdate = docs.filter((it) => hierarchy.findAllMixins(it).length > 0)
|
||||
if (toUpdate.length > 0) {
|
||||
await client.update(domain, { _id: { $in: toUpdate.map((it) => it._id) } }, { '%hash%': curHash })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateCollaborativeDocsToJson (client: MigrationClient): Promise<void> {
|
||||
const ctx = new MeasureMetricsContext('migrateCollaborativeDocsToJson', {})
|
||||
const storageAdapter = client.storageAdapter
|
||||
@ -921,6 +959,11 @@ export const coreOperation: MigrateOperation = {
|
||||
state: 'accounts-to-social-ids',
|
||||
mode: 'upgrade',
|
||||
func: migrateAccounts
|
||||
},
|
||||
{
|
||||
state: 'migrate-backup-mixins',
|
||||
mode: 'upgrade',
|
||||
func: migrateBackupMixins
|
||||
}
|
||||
])
|
||||
},
|
||||
|
@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
import { type WorkspaceLoginInfo, getClient as getAccountClient } from '@hcengineering/account-client'
|
||||
import { getClient as getAccountClient } from '@hcengineering/account-client'
|
||||
import client, { clientId } from '@hcengineering/client'
|
||||
import {
|
||||
type Class,
|
||||
@ -51,6 +51,7 @@ import {
|
||||
createMarkupOperations
|
||||
} from './markup'
|
||||
import { type ConnectOptions, type PlatformClient, WithMarkup } from './types'
|
||||
import { getWorkspaceToken } from './utils'
|
||||
|
||||
/**
|
||||
* Create platform client
|
||||
@ -282,39 +283,3 @@ class PlatformClientImpl implements PlatformClient {
|
||||
await this.close()
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceToken {
|
||||
endpoint: string
|
||||
token: string
|
||||
workspaceId: WorkspaceUuid
|
||||
info: WorkspaceLoginInfo
|
||||
}
|
||||
|
||||
export async function getWorkspaceToken (
|
||||
url: string,
|
||||
options: ConnectOptions,
|
||||
config?: ServerConfig
|
||||
): Promise<WorkspaceToken> {
|
||||
config ??= await loadServerConfig(url)
|
||||
|
||||
let token: string | undefined
|
||||
|
||||
if ('token' in options) {
|
||||
token = options.token
|
||||
} else {
|
||||
const { email, password } = options
|
||||
const loginInfo = await getAccountClient(config.ACCOUNTS_URL).login(email, password)
|
||||
token = loginInfo.token
|
||||
}
|
||||
|
||||
if (token === undefined) {
|
||||
throw new Error('Login failed')
|
||||
}
|
||||
|
||||
const ws = await getAccountClient(config.ACCOUNTS_URL, token).selectWorkspace(options.workspace)
|
||||
if (ws === undefined) {
|
||||
throw new Error('Workspace not found')
|
||||
}
|
||||
|
||||
return { endpoint: ws.endpoint, token: ws.token, workspaceId: ws.workspace, info: ws }
|
||||
}
|
||||
|
@ -19,3 +19,4 @@ export * from './socket'
|
||||
export * from './types'
|
||||
export * from './rest'
|
||||
export * from './config'
|
||||
export * from './utils'
|
||||
|
@ -13,6 +13,6 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
export { createRestClient } from './rest'
|
||||
export { createRestClient, connectRest } from './rest'
|
||||
export { createRestTxOperations } from './tx'
|
||||
export * from './types'
|
||||
|
@ -39,12 +39,19 @@ import {
|
||||
import { PlatformError, unknownError } from '@hcengineering/platform'
|
||||
|
||||
import type { RestClient } from './types'
|
||||
import { AuthOptions } from '../types'
|
||||
import { extractJson, withRetry } from './utils'
|
||||
import { getWorkspaceToken } from '../utils'
|
||||
|
||||
export function createRestClient (endpoint: string, workspaceId: string, token: string): RestClient {
|
||||
return new RestClientImpl(endpoint, workspaceId, token)
|
||||
}
|
||||
|
||||
export async function connectRest (url: string, options: AuthOptions): Promise<RestClient> {
|
||||
const { endpoint, token, workspaceId } = await getWorkspaceToken(url, options)
|
||||
return createRestClient(endpoint, workspaceId, token)
|
||||
}
|
||||
|
||||
const rateLimitError = 'rate-limit'
|
||||
|
||||
function isRLE (err: any): boolean {
|
||||
|
55
packages/api-client/src/utils.ts
Normal file
55
packages/api-client/src/utils.ts
Normal file
@ -0,0 +1,55 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type WorkspaceLoginInfo, getClient as getAccountClient } from '@hcengineering/account-client'
|
||||
import { WorkspaceUuid } from '@hcengineering/core'
|
||||
import { AuthOptions } from './types'
|
||||
import { loadServerConfig, ServerConfig } from './config'
|
||||
|
||||
export interface WorkspaceToken {
|
||||
endpoint: string
|
||||
token: string
|
||||
workspaceId: WorkspaceUuid
|
||||
info: WorkspaceLoginInfo
|
||||
}
|
||||
|
||||
export async function getWorkspaceToken (
|
||||
url: string,
|
||||
options: AuthOptions,
|
||||
config?: ServerConfig
|
||||
): Promise<WorkspaceToken> {
|
||||
config ??= await loadServerConfig(url)
|
||||
|
||||
let token: string | undefined
|
||||
|
||||
if ('token' in options) {
|
||||
token = options.token
|
||||
} else {
|
||||
const { email, password } = options
|
||||
const loginInfo = await getAccountClient(config.ACCOUNTS_URL).login(email, password)
|
||||
token = loginInfo.token
|
||||
}
|
||||
|
||||
if (token === undefined) {
|
||||
throw new Error('Login failed')
|
||||
}
|
||||
|
||||
const ws = await getAccountClient(config.ACCOUNTS_URL, token).selectWorkspace(options.workspace)
|
||||
if (ws === undefined) {
|
||||
throw new Error('Workspace not found')
|
||||
}
|
||||
|
||||
return { endpoint: ws.endpoint, token: ws.token, workspaceId: ws.workspace, info: ws }
|
||||
}
|
@ -127,9 +127,9 @@ p {
|
||||
user-select:inherit;
|
||||
|
||||
a {
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
hyphens: auto;
|
||||
// word-break: break-all;
|
||||
// word-break: break-word;
|
||||
// hyphens: auto;
|
||||
color: var(--theme-link-color);
|
||||
|
||||
&:hover,
|
||||
|
@ -41,5 +41,5 @@
|
||||
{#if data.action === 'create'}
|
||||
<ActivityObjectValue {message} {card} />
|
||||
{:else if data.update && data.action === 'update'}
|
||||
<ActivityUpdateViewer update={data.update} model={attributeModel} />
|
||||
<ActivityUpdateViewer update={data.update} model={attributeModel} content={message.content} />
|
||||
{/if}
|
||||
|
@ -15,7 +15,7 @@
|
||||
<script lang="ts">
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { IconDelete } from '@hcengineering/ui'
|
||||
import { ActivityTagUpdate } from '@hcengineering/communication-types'
|
||||
import { ActivityTagUpdate, RichText } from '@hcengineering/communication-types'
|
||||
import cardPlugin from '@hcengineering/card'
|
||||
|
||||
import Icon from '../../Icon.svelte'
|
||||
@ -24,30 +24,42 @@
|
||||
import uiNext from '../../../plugin'
|
||||
|
||||
export let update: ActivityTagUpdate
|
||||
export let content: RichText
|
||||
|
||||
const client = getClient()
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
$: mixin = hierarchy.getClass(update.tag)
|
||||
$: mixin = hierarchy.hasClass(update.tag) ? hierarchy.getClass(update.tag) : undefined
|
||||
</script>
|
||||
|
||||
{#if update.action === 'add'}
|
||||
<div class="flex-presenter overflow-label flex-gap-2">
|
||||
<Icon icon={IconPlus} size="small" />
|
||||
<Label label={uiNext.string.Added} />
|
||||
<span class="lower"><Label label={cardPlugin.string.Tag} /></span>
|
||||
<div class="tag no-word-wrap">
|
||||
<Label label={mixin.label} />
|
||||
{#if mixin !== undefined}
|
||||
{#if update.action === 'add'}
|
||||
<div class="flex-presenter overflow-label flex-gap-2">
|
||||
<Icon icon={IconPlus} size="small" />
|
||||
<Label label={uiNext.string.Added} />
|
||||
<span class="lower"><Label label={cardPlugin.string.Tag} /></span>
|
||||
<div class="tag no-word-wrap">
|
||||
<Label label={mixin.label} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if update.action === 'remove'}
|
||||
<div class="flex-presenter overflow-label flex-gap-2">
|
||||
<Icon icon={IconDelete} size="small" />
|
||||
<Label label={uiNext.string.Removed} />
|
||||
<span class="lower"><Label label={cardPlugin.string.Tag} /></span>
|
||||
<div class="tag no-word-wrap">
|
||||
<Label label={mixin.label} />
|
||||
{:else if update.action === 'remove'}
|
||||
<div class="flex-presenter overflow-label flex-gap-2">
|
||||
<Icon icon={IconDelete} size="small" />
|
||||
<Label label={uiNext.string.Removed} />
|
||||
<span class="lower"><Label label={cardPlugin.string.Tag} /></span>
|
||||
<div class="tag no-word-wrap">
|
||||
<Label label={mixin.label} />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="flex-presenter overflow-label flex-gap-2">
|
||||
{#if update.action === 'add'}
|
||||
<Icon icon={IconPlus} size="small" />
|
||||
{:else if update.action === 'remove'}
|
||||
<Icon icon={IconDelete} size="small" />
|
||||
{/if}
|
||||
{content}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
@ -14,17 +14,18 @@
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { AttributeModel } from '@hcengineering/view'
|
||||
import { ActivityUpdate, ActivityUpdateType } from '@hcengineering/communication-types'
|
||||
import { ActivityUpdate, ActivityUpdateType, RichText } from '@hcengineering/communication-types'
|
||||
|
||||
import ActivityUpdateTagViewer from './ActivityUpdateTagViewer.svelte'
|
||||
import ActivityUpdateAttributeViewer from './ActivityUpdateAttributeViewer.svelte'
|
||||
|
||||
export let model: AttributeModel | undefined = undefined
|
||||
export let update: ActivityUpdate
|
||||
export let content: RichText
|
||||
</script>
|
||||
|
||||
{#if update.type === ActivityUpdateType.Attribute && model}
|
||||
<ActivityUpdateAttributeViewer {model} {update} />
|
||||
{:else if update.type === ActivityUpdateType.Tag}
|
||||
<ActivityUpdateTagViewer {update} />
|
||||
<ActivityUpdateTagViewer {update} {content} />
|
||||
{/if}
|
||||
|
@ -53,6 +53,8 @@
|
||||
"UpdatedObject": "Aktualizovaný {object}",
|
||||
"NewObjectType": "Nový {type}: {title}",
|
||||
"RemovedObjectType": "Odstraněný {type} : {title}",
|
||||
"AttributeSetTo": "{name} nastaveno na {value}"
|
||||
"AttributeSetTo": "{name} nastaveno na {value}",
|
||||
"AddedTag": "Přidaný štítek: {title}",
|
||||
"RemovedTag": "Odebráný štítek: {title}"
|
||||
}
|
||||
}
|
@ -53,6 +53,8 @@
|
||||
"UpdatedObject": "{object} aktualisiert",
|
||||
"NewObjectType": "Neues {objectType}",
|
||||
"RemovedObjectType": "{objectType} entfernt",
|
||||
"AttributeSetTo": "{attribute} auf {value} gesetzt"
|
||||
"AttributeSetTo": "{attribute} auf {value} gesetzt",
|
||||
"AddedTag": "Tag hinzugefügt: {title}",
|
||||
"RemovedTag": "Tag entfernt: {title}"
|
||||
}
|
||||
}
|
@ -53,6 +53,8 @@
|
||||
"UpdatedObject": "Updated {object}",
|
||||
"NewObjectType": "New {type}: {title}",
|
||||
"RemovedObjectType": "Removed {type} : {title}",
|
||||
"AddedTag": "Added tag: {title}",
|
||||
"RemovedTag": "Removed tag: {title}",
|
||||
"AttributeSetTo": "{name} set to {value}"
|
||||
}
|
||||
}
|
@ -52,6 +52,8 @@
|
||||
"UpdatedObject": "Actualizado {object}",
|
||||
"NewObjectType": "Nuevo {type}: {title}",
|
||||
"RemovedObjectType": "Eliminado {type} : {title}",
|
||||
"AttributeSetTo": "{name} establecido en {value}"
|
||||
"AttributeSetTo": "{name} establecido en {value}",
|
||||
"AddedTag": "Etiqueta añadida: {title}",
|
||||
"RemovedTag": "Etiqueta eliminada: {title}"
|
||||
}
|
||||
}
|
@ -53,6 +53,8 @@
|
||||
"UpdatedObject": "Mis à jour {object}",
|
||||
"NewObjectType": "Nouveau {type}: {title}",
|
||||
"RemovedObjectType": "Supprimé {type} : {title}",
|
||||
"AttributeSetTo": "{name} défini sur {value}"
|
||||
"AttributeSetTo": "{name} défini sur {value}",
|
||||
"AddedTag": "Tag ajouté: {title}",
|
||||
"RemovedTag": "Tag supprimé: {title}"
|
||||
}
|
||||
}
|
@ -53,6 +53,8 @@
|
||||
"UpdatedObject": "{object} aggiornato",
|
||||
"NewObjectType": "Nuovo {type}: {title}",
|
||||
"RemovedObjectType": "{type} rimosso: {title}",
|
||||
"AttributeSetTo": "{name} impostato su {value}"
|
||||
"AttributeSetTo": "{name} impostato su {value}",
|
||||
"AddedTag": "Tag aggiunto: {title}",
|
||||
"RemovedTag": "Tag rimosso: {title}"
|
||||
}
|
||||
}
|
||||
|
@ -53,7 +53,9 @@
|
||||
"UpdatedObject": "{object} を更新しました",
|
||||
"NewObjectType": "新しい {type}:{title}",
|
||||
"RemovedObjectType": "{type} を削除:{title}",
|
||||
"AttributeSetTo": "{name} を {value} に設定しました"
|
||||
"AttributeSetTo": "{name} を {value} に設定しました",
|
||||
"AddedTag": "タグを追加しました:{title}",
|
||||
"RemovedTag": "タグを削除しました:{title}"
|
||||
}
|
||||
}
|
||||
|
@ -52,6 +52,8 @@
|
||||
"UpdatedObject": "Atualizado {object}",
|
||||
"NewObjectType": "Novo {type}: {title}",
|
||||
"RemovedObjectType": "Removido {type} : {title}",
|
||||
"AttributeSetTo": "{name} definido para {value}"
|
||||
"AttributeSetTo": "{name} definido para {value}",
|
||||
"AddedTag": "Tag adicionado: {title}",
|
||||
"RemovedTag": "Tag removido: {title}"
|
||||
}
|
||||
}
|
@ -53,6 +53,8 @@
|
||||
"UpdatedObject": "Обновил(а) {object}",
|
||||
"NewObjectType": "Новый(ые) {type}: {title}",
|
||||
"RemovedObjectType": "Удален(ы) {type} : {title}",
|
||||
"AttributeSetTo": "{name} установлен на {value}"
|
||||
"AttributeSetTo": "{name} установлен на {value}",
|
||||
"AddedTag": "Добавлен тег: {title}",
|
||||
"RemovedTag": "Удален тег: {title}"
|
||||
}
|
||||
}
|
@ -53,6 +53,8 @@
|
||||
"UpdatedObject": "更新 {object}",
|
||||
"NewObjectType": "新 {type}: {title}",
|
||||
"RemovedObjectType": "移除 {type} : {title}",
|
||||
"AttributeSetTo": "{name} 设置为 {value}"
|
||||
"AttributeSetTo": "{name} 设置为 {value}",
|
||||
"AddedTag": "添加标签: {title}",
|
||||
"RemovedTag": "移除标签: {title}"
|
||||
}
|
||||
}
|
||||
|
@ -320,7 +320,9 @@ export default plugin(activityId, {
|
||||
UpdatedObject: '' as IntlString,
|
||||
NewObjectType: '' as IntlString,
|
||||
RemovedObjectType: '' as IntlString,
|
||||
AttributeSetTo: '' as IntlString
|
||||
AttributeSetTo: '' as IntlString,
|
||||
AddedTag: '' as IntlString,
|
||||
RemovedTag: '' as IntlString
|
||||
},
|
||||
component: {
|
||||
Activity: '' as AnyComponent,
|
||||
|
@ -29,11 +29,11 @@
|
||||
closeTooltip,
|
||||
deviceOptionsStore as deviceInfo,
|
||||
day as getDay,
|
||||
getWeekStart,
|
||||
getWeekDayName,
|
||||
getWeekStart,
|
||||
isWeekend,
|
||||
resizeObserver,
|
||||
ticker,
|
||||
isWeekend
|
||||
ticker
|
||||
} from '@hcengineering/ui'
|
||||
import { showMenu } from '@hcengineering/view-resources'
|
||||
import { createEventDispatcher, onDestroy, onMount } from 'svelte'
|
||||
@ -48,6 +48,7 @@
|
||||
import calendar from '../plugin'
|
||||
import { isReadOnly, updateReccuringInstance } from '../utils'
|
||||
import EventElement from './EventElement.svelte'
|
||||
import TimeDuration from './TimeDuration.svelte'
|
||||
|
||||
export let events: Event[]
|
||||
export let selectedDate: Date = new Date()
|
||||
@ -342,6 +343,40 @@
|
||||
}
|
||||
}
|
||||
|
||||
const calcTime = (events: CalendarItem[]): number => {
|
||||
if (events.length === 0) return 0
|
||||
|
||||
// Extract and sort intervals by start time
|
||||
const intervals = events.map((it) => [it.date, it.dueDate]).sort((a, b) => a[0] - b[0])
|
||||
|
||||
// Merge overlapping intervals
|
||||
const mergedIntervals = []
|
||||
let currentStart = intervals[0][0]
|
||||
let currentEnd = intervals[0][1]
|
||||
|
||||
for (let i = 1; i < intervals.length; i++) {
|
||||
const [nextStart, nextEnd] = intervals[i]
|
||||
|
||||
// If intervals overlap, extend the current end if needed
|
||||
if (nextStart <= currentEnd) {
|
||||
currentEnd = Math.max(currentEnd, nextEnd)
|
||||
} else {
|
||||
// No overlap, add current interval to merged list and start a new one
|
||||
mergedIntervals.push([currentStart, currentEnd])
|
||||
currentStart = nextStart
|
||||
currentEnd = nextEnd
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last interval
|
||||
mergedIntervals.push([currentStart, currentEnd])
|
||||
|
||||
// Calculate total duration in hours
|
||||
const totalHours = mergedIntervals.reduce((sum, [start, end]) => sum + (end - start) / (1000 * 60 * 60), 0)
|
||||
|
||||
return totalHours * 1000 * 60 * 60
|
||||
}
|
||||
|
||||
const checkIntersect = (date1: CalendarItem | CalendarElement, date2: CalendarItem | CalendarElement): boolean => {
|
||||
return (
|
||||
(date2.date <= date1.date && date2.dueDate > date1.date) ||
|
||||
@ -818,6 +853,7 @@
|
||||
{#each [...Array(displayedDaysCount).keys()] as dayOfWeek}
|
||||
{@const day = getDay(weekStart, dayOfWeek)}
|
||||
{@const tday = areDatesEqual(todayDate, day)}
|
||||
{@const tEvents = calcTime(toCalendar(events, day))}
|
||||
<div class="sticky-header head title" class:center={displayedDaysCount > 1}>
|
||||
<span class="day" class:today={tday}>{day.getDate()}</span>
|
||||
{#if tday}
|
||||
@ -828,6 +864,11 @@
|
||||
{:else}
|
||||
<span class="weekday">{getWeekDayName(day, weekFormat)}</span>
|
||||
{/if}
|
||||
{#if tEvents !== 0}
|
||||
<span style:min-width={'3rem'}>
|
||||
<TimeDuration value={tEvents} />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
@ -17,7 +17,13 @@
|
||||
<script lang="ts">
|
||||
import { Schedule } from '@hcengineering/calendar'
|
||||
import { getCurrentEmployee } from '@hcengineering/contact'
|
||||
import presentation, { createQuery, getClient, getCurrentWorkspaceUrl, MessageBox } from '@hcengineering/presentation'
|
||||
import presentation, {
|
||||
copyTextToClipboard,
|
||||
createQuery,
|
||||
getClient,
|
||||
getCurrentWorkspaceUrl,
|
||||
MessageBox
|
||||
} from '@hcengineering/presentation'
|
||||
import { Action, ButtonIcon, IconAdd, IconDelete, IconLink, NavItem, showPopup } from '@hcengineering/ui'
|
||||
import view from '@hcengineering/view'
|
||||
import { TreeElement } from '@hcengineering/view-resources'
|
||||
@ -91,7 +97,7 @@
|
||||
okLabel: presentation.string.CopyLink,
|
||||
canSubmit: false,
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(link)
|
||||
await copyTextToClipboard(link)
|
||||
}
|
||||
},
|
||||
undefined
|
||||
|
@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { themeStore, formatDuration } from '@hcengineering/ui'
|
||||
|
||||
export let value: number
|
||||
|
||||
let duration: string
|
||||
$: formatDuration(value, $themeStore.language).then((res) => {
|
||||
duration = res
|
||||
})
|
||||
</script>
|
||||
|
||||
{duration}
|
@ -93,6 +93,7 @@
|
||||
const _class = hierarchy.getClass(desc)
|
||||
if (_class.label === undefined) continue
|
||||
if (_class.kind !== ClassifierKind.CLASS) continue
|
||||
if ((_class as MasterTag).removed === true) continue
|
||||
added.add(desc)
|
||||
toAdd.push(_class)
|
||||
}
|
||||
|
@ -83,7 +83,6 @@
|
||||
preferenceQuery.query(
|
||||
view.class.ViewletPreference,
|
||||
{
|
||||
space: core.space.Workspace,
|
||||
attachedTo: viewletId
|
||||
},
|
||||
(res) => {
|
||||
@ -129,7 +128,7 @@
|
||||
|
||||
const filledData = fillDefaults(hierarchy, data, object._class)
|
||||
|
||||
const _id = await client.createDoc(object._class, core.space.Workspace, filledData)
|
||||
const _id = await client.createDoc(object._class, object.space, filledData)
|
||||
|
||||
Analytics.handleEvent(CardEvents.CardCreated)
|
||||
|
||||
|
@ -35,7 +35,13 @@
|
||||
const desc = hierarchy.getDescendants(_class)
|
||||
for (const clazz of desc) {
|
||||
const cls = hierarchy.getClass(clazz)
|
||||
if (cls.extends === _class && !cls.hidden && kind === cls.kind && cls.label !== undefined) {
|
||||
if (
|
||||
cls.extends === _class &&
|
||||
!cls.hidden &&
|
||||
kind === cls.kind &&
|
||||
cls.label !== undefined &&
|
||||
(cls as MasterTag).removed !== true
|
||||
) {
|
||||
result.push(clazz)
|
||||
}
|
||||
}
|
||||
|
@ -24,12 +24,7 @@ import aiBot from '@hcengineering/ai-bot'
|
||||
import { summarizeMessages as aiSummarizeMessages, translate as aiTranslate } from '@hcengineering/ai-bot-resources'
|
||||
import { type Channel, type ChatMessage, type DirectMessage, type ThreadMessage } from '@hcengineering/chunter'
|
||||
import contact, { getCurrentEmployee, getName, type Employee, type Person } from '@hcengineering/contact'
|
||||
import {
|
||||
employeeByAccountStore,
|
||||
employeeByIdStore,
|
||||
PersonIcon,
|
||||
personRefByAccountUuidStore
|
||||
} from '@hcengineering/contact-resources'
|
||||
import { employeeByAccountStore, employeeByIdStore, PersonIcon } from '@hcengineering/contact-resources'
|
||||
import core, {
|
||||
getCurrentAccount,
|
||||
notEmpty,
|
||||
@ -562,29 +557,29 @@ export async function startConversationAction (docs?: Employee | Employee[]): Pr
|
||||
}
|
||||
}
|
||||
|
||||
export async function createDirect (employeeIds: Array<Ref<Person>>): Promise<Ref<DirectMessage>> {
|
||||
export async function createDirect (employeeIds: Array<Ref<Employee>>): Promise<Ref<DirectMessage>> {
|
||||
const client = getClient()
|
||||
const me = getCurrentEmployee()
|
||||
const myAcc = getCurrentAccount()
|
||||
|
||||
const existingDms = await client.findAll(chunter.class.DirectMessage, {})
|
||||
const newDirectPersons = employeeIds.includes(me) ? employeeIds : [...employeeIds, me]
|
||||
const newPersonsSet = new Set(newDirectPersons)
|
||||
const newDirectEmployeeIds = Array.from(new Set([...employeeIds, me]))
|
||||
|
||||
let direct: DirectMessage | undefined
|
||||
const personRefByAccountUuid = get(personRefByAccountUuidStore)
|
||||
|
||||
const employeeById = get(employeeByIdStore)
|
||||
const newDirectAccounts = new Set(newDirectEmployeeIds.map((it) => employeeById.get(it)?.personUuid).filter(notEmpty))
|
||||
|
||||
for (const dm of existingDms) {
|
||||
const existPersonsSet = new Set(dm.members.map((acc) => personRefByAccountUuid.get(acc)).filter(notEmpty))
|
||||
const existAccounts = new Set(dm.members)
|
||||
|
||||
if (existPersonsSet.size !== newPersonsSet.size) {
|
||||
if (existAccounts.size !== newDirectAccounts.size) {
|
||||
continue
|
||||
}
|
||||
|
||||
let match = true
|
||||
for (const person of existPersonsSet) {
|
||||
if (!newPersonsSet.has(person as Ref<Person>)) {
|
||||
for (const acc of existAccounts) {
|
||||
if (!newDirectAccounts.has(acc)) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
@ -603,15 +598,7 @@ export async function createDirect (employeeIds: Array<Ref<Person>>): Promise<Re
|
||||
description: '',
|
||||
private: true,
|
||||
archived: false,
|
||||
members: newDirectPersons.map((person) => {
|
||||
const employee = employeeById.get(person as Ref<Employee>)
|
||||
|
||||
if (employee?.personUuid === undefined) {
|
||||
throw new Error(`Account id not found for person ${person}`)
|
||||
}
|
||||
|
||||
return employee.personUuid
|
||||
})
|
||||
members: Array.from(newDirectAccounts)
|
||||
}))
|
||||
|
||||
const context = await client.findOne(notification.class.DocNotifyContext, {
|
||||
|
@ -298,7 +298,7 @@ class Connection implements ClientConnection {
|
||||
return
|
||||
}
|
||||
|
||||
if (resp.rateLimit !== undefined && resp.rateLimit.remaining < 10) {
|
||||
if (resp.rateLimit !== undefined && resp.rateLimit.remaining < 50) {
|
||||
console.log(
|
||||
'Rate limits:',
|
||||
resp.rateLimit.remaining,
|
||||
|
@ -45,6 +45,8 @@
|
||||
import { getProjectDocumentLink } from '../navigation'
|
||||
import documentRes from '../plugin'
|
||||
import {
|
||||
$approvalRequest as approvalRequest,
|
||||
$reviewRequest as reviewRequest,
|
||||
$activeRightPanelTab as activeRightPanelTab,
|
||||
$availableEditorModes as availableEditorModes,
|
||||
$availableRightPanelTabs as availableRightPanelTabs,
|
||||
@ -140,7 +142,12 @@
|
||||
{
|
||||
label: documentRes.string.TeamTab,
|
||||
component: EditDocTeam,
|
||||
props: { controlledDoc: $controlledDocument, editable: $isDocumentOwner }
|
||||
props: {
|
||||
controlledDoc: $controlledDocument,
|
||||
editable: $isDocumentOwner,
|
||||
reviewRequest: $reviewRequest,
|
||||
approvalRequest: $approvalRequest
|
||||
}
|
||||
},
|
||||
{
|
||||
label: documentRes.string.ReleaseTab,
|
||||
|
@ -25,14 +25,13 @@
|
||||
export let canChangeReviewers: boolean = true
|
||||
export let canChangeApprovers: boolean = true
|
||||
export let canChangeCoAuthors: boolean = true
|
||||
export let reviewers: Ref<Employee>[] = controlledDoc?.reviewers ?? []
|
||||
export let approvers: Ref<Employee>[] = controlledDoc?.approvers ?? []
|
||||
export let coAuthors: Ref<Employee>[] = controlledDoc?.coAuthors ?? []
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const currentEmployee = getCurrentEmployee()
|
||||
|
||||
$: reviewers = controlledDoc.reviewers
|
||||
$: approvers = controlledDoc.approvers
|
||||
$: coAuthors = controlledDoc.coAuthors
|
||||
|
||||
$: permissionsSpace = space === documents.space.UnsortedTemplates ? documents.space.QualityDocuments : space
|
||||
|
||||
function getPermittedPersons (
|
||||
|
@ -13,37 +13,92 @@
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Scroller } from '@hcengineering/ui'
|
||||
import { Employee, Person } from '@hcengineering/contact'
|
||||
import {
|
||||
ControlledDocument,
|
||||
ControlledDocumentState,
|
||||
DocumentApprovalRequest,
|
||||
DocumentReviewRequest,
|
||||
DocumentState
|
||||
} from '@hcengineering/controlled-documents'
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { ControlledDocument, ControlledDocumentState, DocumentState } from '@hcengineering/controlled-documents'
|
||||
import { Employee } from '@hcengineering/contact'
|
||||
import { getClient } from '@hcengineering/presentation'
|
||||
import { Scroller } from '@hcengineering/ui'
|
||||
|
||||
import DocTeam from './DocTeam.svelte'
|
||||
|
||||
export let controlledDoc: ControlledDocument
|
||||
export let editable: boolean = true
|
||||
export let reviewRequest: DocumentReviewRequest | undefined
|
||||
export let approvalRequest: DocumentApprovalRequest | undefined
|
||||
|
||||
$: canChangeCoAuthors =
|
||||
editable && controlledDoc.state === DocumentState.Draft && controlledDoc.controlledState == null
|
||||
$: canChangeReviewers =
|
||||
editable && controlledDoc.state === DocumentState.Draft && controlledDoc.controlledState == null
|
||||
$: canChangeApprovers =
|
||||
editable &&
|
||||
((controlledDoc.state === DocumentState.Draft && controlledDoc.controlledState == null) ||
|
||||
controlledDoc.controlledState === ControlledDocumentState.InReview ||
|
||||
controlledDoc.controlledState === ControlledDocumentState.Reviewed)
|
||||
$: controlledState = controlledDoc.controlledState ?? null
|
||||
|
||||
$: isEditableDraft = editable && controlledDoc.state === DocumentState.Draft
|
||||
|
||||
$: inCleanState = controlledState === null
|
||||
$: inReview = controlledState === ControlledDocumentState.InReview && reviewRequest !== undefined
|
||||
$: inApproval = controlledState === ControlledDocumentState.InApproval && approvalRequest !== undefined
|
||||
$: isReviewed = controlledState === ControlledDocumentState.Reviewed
|
||||
|
||||
$: canChangeCoAuthors = isEditableDraft && inCleanState
|
||||
$: canChangeReviewers = isEditableDraft && (inCleanState || inReview)
|
||||
$: canChangeApprovers = isEditableDraft && (inCleanState || inApproval || inReview || isReviewed)
|
||||
|
||||
$: reviewers = (reviewRequest?.requested as Ref<Employee>[]) ?? controlledDoc.reviewers
|
||||
$: approvers = (approvalRequest?.requested as Ref<Employee>[]) ?? controlledDoc.approvers
|
||||
$: coAuthors = controlledDoc.coAuthors
|
||||
|
||||
const client = getClient()
|
||||
|
||||
async function handleUpdate ({
|
||||
detail
|
||||
}: {
|
||||
detail: { type: 'reviewers' | 'approvers', users: Ref<Employee>[] }
|
||||
detail: { type: 'reviewers' | 'approvers', users: Ref<Person>[] }
|
||||
}): Promise<void> {
|
||||
const { type, users } = detail
|
||||
|
||||
await client.update(controlledDoc, { [type]: users })
|
||||
const request = detail.type === 'reviewers' ? reviewRequest : approvalRequest
|
||||
|
||||
const ops = client.apply()
|
||||
|
||||
if (request?._id !== undefined) {
|
||||
const requested = request.requested?.slice() ?? []
|
||||
const requestedSet = new Set<Ref<Person>>(requested)
|
||||
|
||||
const addedPersons = new Set<Ref<Person>>()
|
||||
const removedPersons = new Set<Ref<Person>>(requested)
|
||||
|
||||
for (const u of users) {
|
||||
if (requestedSet.has(u)) {
|
||||
removedPersons.delete(u)
|
||||
} else {
|
||||
addedPersons.add(u)
|
||||
}
|
||||
}
|
||||
|
||||
const approved = request.approved?.slice() ?? []
|
||||
const approvedDates = request.approvedDates?.slice() ?? []
|
||||
|
||||
for (const u of removedPersons) {
|
||||
const idx = approved.indexOf(u)
|
||||
if (idx === -1) continue
|
||||
approved.splice(idx, 1)
|
||||
approvedDates.splice(idx, 1)
|
||||
}
|
||||
|
||||
const requiredApprovesCount = users.length
|
||||
|
||||
await ops.update(request, {
|
||||
requested: users,
|
||||
approved,
|
||||
approvedDates,
|
||||
requiredApprovesCount
|
||||
})
|
||||
}
|
||||
|
||||
await ops.update(controlledDoc, { [type]: users })
|
||||
await ops.commit()
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -56,6 +111,9 @@
|
||||
{canChangeCoAuthors}
|
||||
{canChangeReviewers}
|
||||
{canChangeApprovers}
|
||||
{reviewers}
|
||||
{approvers}
|
||||
{coAuthors}
|
||||
on:update={handleUpdate}
|
||||
/>
|
||||
</div>
|
||||
|
@ -18,7 +18,8 @@ import {
|
||||
type ControlledDocument,
|
||||
type DocumentComment,
|
||||
type DocumentTraining,
|
||||
type Project
|
||||
type Project,
|
||||
type DocumentTemplate
|
||||
} from '@hcengineering/controlled-documents'
|
||||
import attachment from '@hcengineering/attachment'
|
||||
import { type Class, type DocumentQuery, type Ref, SortingOrder } from '@hcengineering/core'
|
||||
@ -94,7 +95,7 @@ const queryDocumentVersionsFx = createEffect((payload: ControlledDocument) => {
|
||||
documents.class.ControlledDocument,
|
||||
{
|
||||
seqNumber: payload.seqNumber,
|
||||
template: payload.template
|
||||
template: (payload.template ?? null) as Ref<DocumentTemplate>
|
||||
},
|
||||
(result) => {
|
||||
documentAllVersionsUpdated(result ?? [])
|
||||
|
@ -3,7 +3,7 @@ import { connectMeeting, disconnectMeeting } from '@hcengineering/ai-bot-resourc
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import calendar, { type Event, type Schedule, getAllEvents } from '@hcengineering/calendar'
|
||||
import chunter from '@hcengineering/chunter'
|
||||
import contact, { type Employee, getCurrentEmployee, getName, type Person } from '@hcengineering/contact'
|
||||
import contact, { getCurrentEmployee, getName, type Person } from '@hcengineering/contact'
|
||||
import { personByIdStore } from '@hcengineering/contact-resources'
|
||||
import core, {
|
||||
AccountRole,
|
||||
@ -956,7 +956,7 @@ export async function tryConnect (
|
||||
await client.update(invite, { status: invite.room === room._id ? RequestStatus.Approved : RequestStatus.Rejected })
|
||||
}
|
||||
|
||||
const isGuest = (currentPerson as Employee).role === AccountRole.Guest
|
||||
const isGuest = client.getHierarchy().as(currentPerson, contact.mixin.Employee).role === AccountRole.Guest
|
||||
if ((room.access === RoomAccess.Knock || isGuest) && (!isOffice(room) || room.person !== currentPerson._id)) {
|
||||
const _id = await client.createDoc(love.class.JoinRequest, core.space.Workspace, {
|
||||
person: currentPerson._id,
|
||||
|
@ -8,6 +8,7 @@
|
||||
import WithTeamData from '../WithTeamData.svelte'
|
||||
import { toSlots } from '../utils'
|
||||
import DayPlan from './DayPlan.svelte'
|
||||
import { personRefByAccountUuidStore } from '@hcengineering/contact-resources'
|
||||
|
||||
export let space: Ref<Project>
|
||||
export let currentDate: Date
|
||||
@ -36,9 +37,22 @@
|
||||
todayFrom,
|
||||
todayTo
|
||||
)
|
||||
|
||||
$: persons = (project?.members ?? [])
|
||||
.map((it) => $personRefByAccountUuidStore.get(it))
|
||||
.filter((it) => it !== undefined)
|
||||
</script>
|
||||
|
||||
<WithTeamData {space} fromDate={yesterdayFrom} toDate={todayTo} bind:project bind:todos bind:slots bind:events />
|
||||
<WithTeamData
|
||||
{space}
|
||||
fromDate={yesterdayFrom}
|
||||
toDate={todayTo}
|
||||
bind:project
|
||||
bind:todos
|
||||
bind:slots
|
||||
bind:events
|
||||
{persons}
|
||||
/>
|
||||
|
||||
<Header bind:currentDate />
|
||||
{#if project}
|
||||
|
@ -43,7 +43,9 @@
|
||||
{/each}
|
||||
|
||||
{#each gitem.events as event}
|
||||
<EventItem item={event} showTime={expanded} />
|
||||
{#if event.overlap === 0}
|
||||
<EventItem item={event} showTime={expanded} />
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
{#if gitem.busy.slots.length > 0}
|
||||
|
@ -16,8 +16,12 @@
|
||||
import calendar, { Event, getAllEvents } from '@hcengineering/calendar'
|
||||
import { calendarByIdStore } from '@hcengineering/calendar-resources'
|
||||
import { getCurrentEmployee, Person } from '@hcengineering/contact'
|
||||
import { socialIdsByPersonRefStore, personRefByPersonIdStore } from '@hcengineering/contact-resources'
|
||||
import core, { Doc, IdMap, Ref, Timestamp, Tx, TxCUD, TxCreateDoc, TxUpdateDoc } from '@hcengineering/core'
|
||||
import {
|
||||
personRefByAccountUuidStore,
|
||||
personRefByPersonIdStore,
|
||||
socialIdsByPersonRefStore
|
||||
} from '@hcengineering/contact-resources'
|
||||
import core, { Doc, IdMap, Ref, Timestamp, Tx, TxCreateDoc, TxCUD, TxUpdateDoc } from '@hcengineering/core'
|
||||
import { Asset } from '@hcengineering/platform'
|
||||
import { createQuery, getClient } from '@hcengineering/presentation'
|
||||
import { Project } from '@hcengineering/task'
|
||||
@ -43,7 +47,10 @@
|
||||
let slots: WorkSlot[] = []
|
||||
let events: Event[] = []
|
||||
let todos: IdMap<ToDo> = new Map()
|
||||
let persons: Ref<Person>[] = []
|
||||
|
||||
$: persons = (project?.members ?? [])
|
||||
.map((it) => $personRefByAccountUuidStore.get(it))
|
||||
.filter((it) => it !== undefined)
|
||||
|
||||
const txCreateQuery = createQuery()
|
||||
|
||||
|
@ -23,6 +23,7 @@
|
||||
import { groupTeamData, toSlots } from '../utils'
|
||||
import EventElement from './EventElement.svelte'
|
||||
import PersonCalendar from './PersonCalendar.svelte'
|
||||
import { personRefByAccountUuidStore } from '@hcengineering/contact-resources'
|
||||
|
||||
export let space: Ref<Project>
|
||||
export let currentDate: Date
|
||||
@ -37,7 +38,10 @@
|
||||
let slots: WorkSlot[] = []
|
||||
let events: Event[] = []
|
||||
let todos: IdMap<ToDo> = new Map()
|
||||
let persons: Ref<Person>[] = []
|
||||
|
||||
$: persons = (project?.members ?? [])
|
||||
.map((it) => $personRefByAccountUuidStore.get(it))
|
||||
.filter((it) => it !== undefined)
|
||||
|
||||
function calcHourWidth (events: Event[], totalWidth: number): number[] {
|
||||
const hours = new Map<number, number>()
|
||||
|
@ -173,10 +173,10 @@ function crossWith (a: EventVars, b: EventVars): EventVars[] {
|
||||
/**
|
||||
*
|
||||
* @param events - without overlaps
|
||||
* @param event -
|
||||
* @param event - any object with date and dueDate properties
|
||||
* @returns
|
||||
*/
|
||||
function calcOverlap (events: EventVars[], event: Event): { events: EventVars[], total: number } {
|
||||
function calcOverlap (events: EventVars[], event: EventVars): { events: EventVars[], total: number } {
|
||||
let tmp: EventVars[] = [{ date: event.date, dueDate: event.dueDate }]
|
||||
for (const a of events) {
|
||||
const newTmp: EventVars[] = []
|
||||
|
@ -162,11 +162,11 @@ async function getUpdateText (update: ActivityUpdate, card: Card, hierarchy: Hie
|
||||
const clazz = hierarchy.getClass(update.tag)
|
||||
if (update.action === 'add') {
|
||||
const tagName = await translate(clazz.label, {})
|
||||
return await translate(activity.string.NewObjectType, { type: 'tag', title: tagName })
|
||||
return await translate(activity.string.AddedTag, { title: tagName })
|
||||
}
|
||||
if (update.action === 'remove') {
|
||||
const tagName = await translate(clazz.label, {})
|
||||
return await translate(activity.string.RemovedObjectType, { type: 'tag', title: tagName })
|
||||
return await translate(activity.string.RemovedTag, { title: tagName })
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
@ -1401,7 +1401,8 @@ class MongoAdapter extends MongoAdapterBase {
|
||||
const filter = { _id: tx.objectId }
|
||||
const modifyOp = {
|
||||
modifiedBy: tx.modifiedBy,
|
||||
modifiedOn: tx.modifiedOn
|
||||
modifiedOn: tx.modifiedOn,
|
||||
'%hash%': this.curHash()
|
||||
}
|
||||
if (isOperator(tx.attributes)) {
|
||||
const update = { ...this.translateMixinAttrs(tx.mixin, tx.attributes), $set: { ...modifyOp } }
|
||||
|
@ -40,15 +40,13 @@ export class SlidingWindowRateLimitter {
|
||||
// Update reset time
|
||||
rateLimit.resetTime = now + this.rateLimitWindow
|
||||
|
||||
rateLimit.requests.push(now + (rateLimit.rejectedRequests > this.rateLimitMax * 2 ? this.rateLimitWindow * 5 : 0))
|
||||
if (rateLimit.requests.length <= this.rateLimitMax) {
|
||||
rateLimit.requests.push(now + (rateLimit.rejectedRequests > this.rateLimitMax * 2 ? this.rateLimitWindow * 5 : 0))
|
||||
}
|
||||
|
||||
if (rateLimit.requests.length > this.rateLimitMax) {
|
||||
rateLimit.rejectedRequests++
|
||||
|
||||
if (rateLimit.requests.length > this.rateLimitMax * 2) {
|
||||
// Keep only last requests
|
||||
rateLimit.requests.splice(0, rateLimit.requests.length - this.rateLimitMax)
|
||||
}
|
||||
// Find when the oldest request will exit the window
|
||||
const someRequest = Math.round(Math.random() * rateLimit.requests.length)
|
||||
const nextAvailableTime = rateLimit.requests[someRequest] + this.rateLimitWindow
|
||||
|
@ -123,7 +123,6 @@ describe('SlidingWindowRateLimitter', () => {
|
||||
clock += 10000
|
||||
|
||||
const r2 = limiter.checkRateLimit('user1')
|
||||
expect(r2.remaining).toBe(0)
|
||||
expect(r2.retryAfter).toBeDefined()
|
||||
expect(r2.remaining).toBe(9)
|
||||
})
|
||||
})
|
||||
|
@ -88,6 +88,16 @@ export class SyncManager {
|
||||
await this.keyValueClient.setValue(historyKey, history)
|
||||
}
|
||||
|
||||
private async getPageToken (userId: PersonId): Promise<string | null> {
|
||||
const pageTokenKey = this.getPageTokenKey(userId)
|
||||
return await this.keyValueClient.getValue<string>(pageTokenKey)
|
||||
}
|
||||
|
||||
private async setPageToken (userId: PersonId, pageToken: string): Promise<void> {
|
||||
const pageTokenKey = this.getPageTokenKey(userId)
|
||||
await this.keyValueClient.setValue(pageTokenKey, pageToken)
|
||||
}
|
||||
|
||||
private async partSync (userId: PersonId, userEmail: string | undefined, historyId: string): Promise<void> {
|
||||
if (userEmail === undefined) {
|
||||
throw new Error('Cannot sync without user email')
|
||||
@ -151,7 +161,10 @@ export class SyncManager {
|
||||
if (userEmail === undefined) {
|
||||
throw new Error('Cannot sync without user email')
|
||||
}
|
||||
const pageToken: string | undefined = undefined
|
||||
|
||||
// Get saved page token if exists to resume sync
|
||||
let pageToken: string | undefined = (await this.getPageToken(userId)) ?? undefined
|
||||
|
||||
const query: gmail_v1.Params$Resource$Users$Messages$List = {
|
||||
userId: 'me',
|
||||
pageToken
|
||||
@ -160,48 +173,56 @@ export class SyncManager {
|
||||
query.q = q
|
||||
}
|
||||
let currentHistoryId: string | undefined
|
||||
let totalProcessedMessages = 0
|
||||
|
||||
try {
|
||||
const messagesIds: string[] = []
|
||||
// Process one page at a time
|
||||
while (true) {
|
||||
await this.rateLimiter.take(5)
|
||||
const messages = await this.gmail.messages.list(query)
|
||||
if (query.pageToken == null) {
|
||||
this.ctx.info('Total messages', {
|
||||
workspace: this.workspace,
|
||||
userId,
|
||||
resultSizeEstimate: messages.data.resultSizeEstimate
|
||||
})
|
||||
}
|
||||
const ids = messages.data.messages?.map((p) => p.id) ?? []
|
||||
for (let index = 0; index < ids.length; index++) {
|
||||
const id = ids[index]
|
||||
|
||||
const ids = messages.data.messages?.map((p) => p.id).filter((id) => id != null) ?? []
|
||||
this.ctx.info('Processing page', {
|
||||
workspace: this.workspace,
|
||||
userId,
|
||||
messagesInPage: ids.length,
|
||||
totalProcessed: totalProcessedMessages,
|
||||
currentHistoryId,
|
||||
pageToken: query.pageToken
|
||||
})
|
||||
|
||||
for (const id of ids) {
|
||||
if (id == null) continue
|
||||
messagesIds.push(id)
|
||||
try {
|
||||
const message = await this.getMessage(id)
|
||||
const historyId = message.data.historyId
|
||||
await this.messageManager.saveMessage(message, userEmail)
|
||||
|
||||
if (historyId != null && q === undefined) {
|
||||
if (currentHistoryId == null || Number(currentHistoryId) < Number(historyId)) {
|
||||
currentHistoryId = historyId
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.error('Full sync message error', { workspace: this.workspace, userId, messageId: id, err })
|
||||
}
|
||||
}
|
||||
|
||||
totalProcessedMessages += ids.length
|
||||
|
||||
if (messages.data.nextPageToken == null) {
|
||||
this.ctx.info('Break', { totalNewMessages: messagesIds.length })
|
||||
this.ctx.info('Completed sync', { workspace: this.workspace, userId, totalMessages: totalProcessedMessages })
|
||||
break
|
||||
}
|
||||
query.pageToken = messages.data.nextPageToken
|
||||
|
||||
// Update page token for the next iteration
|
||||
pageToken = messages.data.nextPageToken
|
||||
query.pageToken = pageToken
|
||||
await this.setPageToken(userId, pageToken)
|
||||
}
|
||||
for (let index = messagesIds.length - 1; index >= 0; index--) {
|
||||
const id = messagesIds[index]
|
||||
try {
|
||||
const message = await this.getMessage(id)
|
||||
const historyId = message.data.historyId
|
||||
await this.messageManager.saveMessage(message, userEmail)
|
||||
if (historyId != null && q === undefined) {
|
||||
if (currentHistoryId == null || Number(currentHistoryId) < Number(historyId)) {
|
||||
await this.setHistoryId(userId, historyId)
|
||||
currentHistoryId = historyId
|
||||
}
|
||||
}
|
||||
if (index % 500 === 0) {
|
||||
this.ctx.info('Remaining messages to sync', { workspace: this.workspace, userId, count: index })
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.error('Full sync message error', { workspace: this.workspace, userId, err })
|
||||
}
|
||||
|
||||
if (currentHistoryId != null) {
|
||||
await this.setHistoryId(userId, currentHistoryId)
|
||||
}
|
||||
this.ctx.info('Full sync finished', { workspaceUuid: this.workspace, userId, userEmail })
|
||||
} catch (err) {
|
||||
@ -243,4 +264,8 @@ export class SyncManager {
|
||||
private getHistoryKey (userId: PersonId): string {
|
||||
return `history:${this.workspace}:${userId}`
|
||||
}
|
||||
|
||||
private getPageTokenKey (userId: PersonId): string {
|
||||
return `page-token:${this.workspace}:${userId}`
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user