Merge branch 'develop' into staging-new

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2025-04-29 23:26:06 +07:00
commit 8c09674c53
No known key found for this signature in database
GPG Key ID: BD80F68D68D8F7F2
45 changed files with 474 additions and 188 deletions

View File

@ -13,7 +13,7 @@
// limitations under the License. // 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 core, { TxOperations, type Client, type Data, type Doc } from '@hcengineering/core'
import { import {
tryMigrate, tryMigrate,
@ -39,6 +39,11 @@ export const cardOperation: MigrateOperation = {
state: 'migrate-spaces', state: 'migrate-spaces',
mode: 'upgrade', mode: 'upgrade',
func: migrateSpaces 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> { async function migrateSpaces (client: MigrationClient): Promise<void> {
await client.update(DOMAIN_CARD, { space: core.space.Workspace }, { space: card.space.Default }) await client.update(DOMAIN_CARD, { space: core.space.Workspace }, { space: card.space.Default })
} }

View File

@ -16,40 +16,43 @@
import { saveCollabJson } from '@hcengineering/collaboration' import { saveCollabJson } from '@hcengineering/collaboration'
import core, { import core, {
buildSocialIdString, buildSocialIdString,
configUserAccountUuid,
coreId, coreId,
DOMAIN_MODEL_TX, DOMAIN_MODEL_TX,
DOMAIN_SPACE, DOMAIN_SPACE,
DOMAIN_STATUS, DOMAIN_STATUS,
DOMAIN_TX, DOMAIN_TX,
generateId, generateId,
groupByArray,
makeCollabJsonId, makeCollabJsonId,
makeCollabYdocId, makeCollabYdocId,
makeDocCollabId, makeDocCollabId,
MeasureMetricsContext, MeasureMetricsContext,
RateLimiter, RateLimiter,
SocialIdType, SocialIdType,
type PersonId, systemAccountUuid,
toIdMap,
TxProcessor,
type AccountUuid,
type AnyAttribute, type AnyAttribute,
type AttachedDoc,
type Blob, type Blob,
type Class, type Class,
type Doc, type Doc,
type Domain, type Domain,
type MeasureContext, type MeasureContext,
type PersonId,
type Ref, type Ref,
type Role,
type SocialKey,
type Space, type Space,
type SpaceType,
type Status, type Status,
type TxCreateDoc, type TxCreateDoc,
type TxCUD, type TxCUD,
type SpaceType, type TxMixin,
type TxUpdateDoc, type TxUpdateDoc,
type Role, type TypedSpace
toIdMap,
type TypedSpace,
TxProcessor,
type SocialKey,
type AccountUuid,
systemAccountUuid,
configUserAccountUuid
} from '@hcengineering/core' } from '@hcengineering/core'
import { import {
createDefaultSpace, 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> { async function migrateCollaborativeDocsToJson (client: MigrationClient): Promise<void> {
const ctx = new MeasureMetricsContext('migrateCollaborativeDocsToJson', {}) const ctx = new MeasureMetricsContext('migrateCollaborativeDocsToJson', {})
const storageAdapter = client.storageAdapter const storageAdapter = client.storageAdapter
@ -921,6 +959,11 @@ export const coreOperation: MigrateOperation = {
state: 'accounts-to-social-ids', state: 'accounts-to-social-ids',
mode: 'upgrade', mode: 'upgrade',
func: migrateAccounts func: migrateAccounts
},
{
state: 'migrate-backup-mixins',
mode: 'upgrade',
func: migrateBackupMixins
} }
]) ])
}, },

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // 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 client, { clientId } from '@hcengineering/client'
import { import {
type Class, type Class,
@ -51,6 +51,7 @@ import {
createMarkupOperations createMarkupOperations
} from './markup' } from './markup'
import { type ConnectOptions, type PlatformClient, WithMarkup } from './types' import { type ConnectOptions, type PlatformClient, WithMarkup } from './types'
import { getWorkspaceToken } from './utils'
/** /**
* Create platform client * Create platform client
@ -282,39 +283,3 @@ class PlatformClientImpl implements PlatformClient {
await this.close() 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 }
}

View File

@ -19,3 +19,4 @@ export * from './socket'
export * from './types' export * from './types'
export * from './rest' export * from './rest'
export * from './config' export * from './config'
export * from './utils'

View File

@ -13,6 +13,6 @@
// limitations under the License. // limitations under the License.
// //
export { createRestClient } from './rest' export { createRestClient, connectRest } from './rest'
export { createRestTxOperations } from './tx' export { createRestTxOperations } from './tx'
export * from './types' export * from './types'

View File

@ -39,12 +39,19 @@ import {
import { PlatformError, unknownError } from '@hcengineering/platform' import { PlatformError, unknownError } from '@hcengineering/platform'
import type { RestClient } from './types' import type { RestClient } from './types'
import { AuthOptions } from '../types'
import { extractJson, withRetry } from './utils' import { extractJson, withRetry } from './utils'
import { getWorkspaceToken } from '../utils'
export function createRestClient (endpoint: string, workspaceId: string, token: string): RestClient { export function createRestClient (endpoint: string, workspaceId: string, token: string): RestClient {
return new RestClientImpl(endpoint, workspaceId, token) 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' const rateLimitError = 'rate-limit'
function isRLE (err: any): boolean { function isRLE (err: any): boolean {

View 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 }
}

View File

@ -127,9 +127,9 @@ p {
user-select:inherit; user-select:inherit;
a { a {
word-break: break-all; // word-break: break-all;
word-break: break-word; // word-break: break-word;
hyphens: auto; // hyphens: auto;
color: var(--theme-link-color); color: var(--theme-link-color);
&:hover, &:hover,

View File

@ -41,5 +41,5 @@
{#if data.action === 'create'} {#if data.action === 'create'}
<ActivityObjectValue {message} {card} /> <ActivityObjectValue {message} {card} />
{:else if data.update && data.action === 'update'} {:else if data.update && data.action === 'update'}
<ActivityUpdateViewer update={data.update} model={attributeModel} /> <ActivityUpdateViewer update={data.update} model={attributeModel} content={message.content} />
{/if} {/if}

View File

@ -15,7 +15,7 @@
<script lang="ts"> <script lang="ts">
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { IconDelete } from '@hcengineering/ui' import { IconDelete } from '@hcengineering/ui'
import { ActivityTagUpdate } from '@hcengineering/communication-types' import { ActivityTagUpdate, RichText } from '@hcengineering/communication-types'
import cardPlugin from '@hcengineering/card' import cardPlugin from '@hcengineering/card'
import Icon from '../../Icon.svelte' import Icon from '../../Icon.svelte'
@ -24,14 +24,16 @@
import uiNext from '../../../plugin' import uiNext from '../../../plugin'
export let update: ActivityTagUpdate export let update: ActivityTagUpdate
export let content: RichText
const client = getClient() const client = getClient()
const hierarchy = client.getHierarchy() const hierarchy = client.getHierarchy()
$: mixin = hierarchy.getClass(update.tag) $: mixin = hierarchy.hasClass(update.tag) ? hierarchy.getClass(update.tag) : undefined
</script> </script>
{#if update.action === 'add'} {#if mixin !== undefined}
{#if update.action === 'add'}
<div class="flex-presenter overflow-label flex-gap-2"> <div class="flex-presenter overflow-label flex-gap-2">
<Icon icon={IconPlus} size="small" /> <Icon icon={IconPlus} size="small" />
<Label label={uiNext.string.Added} /> <Label label={uiNext.string.Added} />
@ -40,7 +42,7 @@
<Label label={mixin.label} /> <Label label={mixin.label} />
</div> </div>
</div> </div>
{:else if update.action === 'remove'} {:else if update.action === 'remove'}
<div class="flex-presenter overflow-label flex-gap-2"> <div class="flex-presenter overflow-label flex-gap-2">
<Icon icon={IconDelete} size="small" /> <Icon icon={IconDelete} size="small" />
<Label label={uiNext.string.Removed} /> <Label label={uiNext.string.Removed} />
@ -49,6 +51,16 @@
<Label label={mixin.label} /> <Label label={mixin.label} />
</div> </div>
</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} {/if}
<style lang="scss"> <style lang="scss">

View File

@ -14,17 +14,18 @@
--> -->
<script lang="ts"> <script lang="ts">
import { AttributeModel } from '@hcengineering/view' 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 ActivityUpdateTagViewer from './ActivityUpdateTagViewer.svelte'
import ActivityUpdateAttributeViewer from './ActivityUpdateAttributeViewer.svelte' import ActivityUpdateAttributeViewer from './ActivityUpdateAttributeViewer.svelte'
export let model: AttributeModel | undefined = undefined export let model: AttributeModel | undefined = undefined
export let update: ActivityUpdate export let update: ActivityUpdate
export let content: RichText
</script> </script>
{#if update.type === ActivityUpdateType.Attribute && model} {#if update.type === ActivityUpdateType.Attribute && model}
<ActivityUpdateAttributeViewer {model} {update} /> <ActivityUpdateAttributeViewer {model} {update} />
{:else if update.type === ActivityUpdateType.Tag} {:else if update.type === ActivityUpdateType.Tag}
<ActivityUpdateTagViewer {update} /> <ActivityUpdateTagViewer {update} {content} />
{/if} {/if}

View File

@ -53,6 +53,8 @@
"UpdatedObject": "Aktualizovaný {object}", "UpdatedObject": "Aktualizovaný {object}",
"NewObjectType": "Nový {type}: {title}", "NewObjectType": "Nový {type}: {title}",
"RemovedObjectType": "Odstraněný {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}"
} }
} }

View File

@ -53,6 +53,8 @@
"UpdatedObject": "{object} aktualisiert", "UpdatedObject": "{object} aktualisiert",
"NewObjectType": "Neues {objectType}", "NewObjectType": "Neues {objectType}",
"RemovedObjectType": "{objectType} entfernt", "RemovedObjectType": "{objectType} entfernt",
"AttributeSetTo": "{attribute} auf {value} gesetzt" "AttributeSetTo": "{attribute} auf {value} gesetzt",
"AddedTag": "Tag hinzugefügt: {title}",
"RemovedTag": "Tag entfernt: {title}"
} }
} }

View File

@ -53,6 +53,8 @@
"UpdatedObject": "Updated {object}", "UpdatedObject": "Updated {object}",
"NewObjectType": "New {type}: {title}", "NewObjectType": "New {type}: {title}",
"RemovedObjectType": "Removed {type} : {title}", "RemovedObjectType": "Removed {type} : {title}",
"AddedTag": "Added tag: {title}",
"RemovedTag": "Removed tag: {title}",
"AttributeSetTo": "{name} set to {value}" "AttributeSetTo": "{name} set to {value}"
} }
} }

View File

@ -52,6 +52,8 @@
"UpdatedObject": "Actualizado {object}", "UpdatedObject": "Actualizado {object}",
"NewObjectType": "Nuevo {type}: {title}", "NewObjectType": "Nuevo {type}: {title}",
"RemovedObjectType": "Eliminado {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}"
} }
} }

View File

@ -53,6 +53,8 @@
"UpdatedObject": "Mis à jour {object}", "UpdatedObject": "Mis à jour {object}",
"NewObjectType": "Nouveau {type}: {title}", "NewObjectType": "Nouveau {type}: {title}",
"RemovedObjectType": "Supprimé {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}"
} }
} }

View File

@ -53,6 +53,8 @@
"UpdatedObject": "{object} aggiornato", "UpdatedObject": "{object} aggiornato",
"NewObjectType": "Nuovo {type}: {title}", "NewObjectType": "Nuovo {type}: {title}",
"RemovedObjectType": "{type} rimosso: {title}", "RemovedObjectType": "{type} rimosso: {title}",
"AttributeSetTo": "{name} impostato su {value}" "AttributeSetTo": "{name} impostato su {value}",
"AddedTag": "Tag aggiunto: {title}",
"RemovedTag": "Tag rimosso: {title}"
} }
} }

View File

@ -53,7 +53,9 @@
"UpdatedObject": "{object} を更新しました", "UpdatedObject": "{object} を更新しました",
"NewObjectType": "新しい {type}{title}", "NewObjectType": "新しい {type}{title}",
"RemovedObjectType": "{type} を削除:{title}", "RemovedObjectType": "{type} を削除:{title}",
"AttributeSetTo": "{name} を {value} に設定しました" "AttributeSetTo": "{name} を {value} に設定しました",
"AddedTag": "タグを追加しました:{title}",
"RemovedTag": "タグを削除しました:{title}"
} }
} }

View File

@ -52,6 +52,8 @@
"UpdatedObject": "Atualizado {object}", "UpdatedObject": "Atualizado {object}",
"NewObjectType": "Novo {type}: {title}", "NewObjectType": "Novo {type}: {title}",
"RemovedObjectType": "Removido {type} : {title}", "RemovedObjectType": "Removido {type} : {title}",
"AttributeSetTo": "{name} definido para {value}" "AttributeSetTo": "{name} definido para {value}",
"AddedTag": "Tag adicionado: {title}",
"RemovedTag": "Tag removido: {title}"
} }
} }

View File

@ -53,6 +53,8 @@
"UpdatedObject": "Обновил(а) {object}", "UpdatedObject": "Обновил(а) {object}",
"NewObjectType": "Новый(ые) {type}: {title}", "NewObjectType": "Новый(ые) {type}: {title}",
"RemovedObjectType": "Удален(ы) {type} : {title}", "RemovedObjectType": "Удален(ы) {type} : {title}",
"AttributeSetTo": "{name} установлен на {value}" "AttributeSetTo": "{name} установлен на {value}",
"AddedTag": "Добавлен тег: {title}",
"RemovedTag": "Удален тег: {title}"
} }
} }

View File

@ -53,6 +53,8 @@
"UpdatedObject": "更新 {object}", "UpdatedObject": "更新 {object}",
"NewObjectType": "新 {type}: {title}", "NewObjectType": "新 {type}: {title}",
"RemovedObjectType": "移除 {type} : {title}", "RemovedObjectType": "移除 {type} : {title}",
"AttributeSetTo": "{name} 设置为 {value}" "AttributeSetTo": "{name} 设置为 {value}",
"AddedTag": "添加标签: {title}",
"RemovedTag": "移除标签: {title}"
} }
} }

View File

@ -320,7 +320,9 @@ export default plugin(activityId, {
UpdatedObject: '' as IntlString, UpdatedObject: '' as IntlString,
NewObjectType: '' as IntlString, NewObjectType: '' as IntlString,
RemovedObjectType: '' as IntlString, RemovedObjectType: '' as IntlString,
AttributeSetTo: '' as IntlString AttributeSetTo: '' as IntlString,
AddedTag: '' as IntlString,
RemovedTag: '' as IntlString
}, },
component: { component: {
Activity: '' as AnyComponent, Activity: '' as AnyComponent,

View File

@ -29,11 +29,11 @@
closeTooltip, closeTooltip,
deviceOptionsStore as deviceInfo, deviceOptionsStore as deviceInfo,
day as getDay, day as getDay,
getWeekStart,
getWeekDayName, getWeekDayName,
getWeekStart,
isWeekend,
resizeObserver, resizeObserver,
ticker, ticker
isWeekend
} from '@hcengineering/ui' } from '@hcengineering/ui'
import { showMenu } from '@hcengineering/view-resources' import { showMenu } from '@hcengineering/view-resources'
import { createEventDispatcher, onDestroy, onMount } from 'svelte' import { createEventDispatcher, onDestroy, onMount } from 'svelte'
@ -48,6 +48,7 @@
import calendar from '../plugin' import calendar from '../plugin'
import { isReadOnly, updateReccuringInstance } from '../utils' import { isReadOnly, updateReccuringInstance } from '../utils'
import EventElement from './EventElement.svelte' import EventElement from './EventElement.svelte'
import TimeDuration from './TimeDuration.svelte'
export let events: Event[] export let events: Event[]
export let selectedDate: Date = new Date() 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 => { const checkIntersect = (date1: CalendarItem | CalendarElement, date2: CalendarItem | CalendarElement): boolean => {
return ( return (
(date2.date <= date1.date && date2.dueDate > date1.date) || (date2.date <= date1.date && date2.dueDate > date1.date) ||
@ -818,6 +853,7 @@
{#each [...Array(displayedDaysCount).keys()] as dayOfWeek} {#each [...Array(displayedDaysCount).keys()] as dayOfWeek}
{@const day = getDay(weekStart, dayOfWeek)} {@const day = getDay(weekStart, dayOfWeek)}
{@const tday = areDatesEqual(todayDate, day)} {@const tday = areDatesEqual(todayDate, day)}
{@const tEvents = calcTime(toCalendar(events, day))}
<div class="sticky-header head title" class:center={displayedDaysCount > 1}> <div class="sticky-header head title" class:center={displayedDaysCount > 1}>
<span class="day" class:today={tday}>{day.getDate()}</span> <span class="day" class:today={tday}>{day.getDate()}</span>
{#if tday} {#if tday}
@ -828,6 +864,11 @@
{:else} {:else}
<span class="weekday">{getWeekDayName(day, weekFormat)}</span> <span class="weekday">{getWeekDayName(day, weekFormat)}</span>
{/if} {/if}
{#if tEvents !== 0}
<span style:min-width={'3rem'}>
<TimeDuration value={tEvents} />
</span>
{/if}
</div> </div>
{/each} {/each}
{/if} {/if}

View File

@ -17,7 +17,13 @@
<script lang="ts"> <script lang="ts">
import { Schedule } from '@hcengineering/calendar' import { Schedule } from '@hcengineering/calendar'
import { getCurrentEmployee } from '@hcengineering/contact' 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 { Action, ButtonIcon, IconAdd, IconDelete, IconLink, NavItem, showPopup } from '@hcengineering/ui'
import view from '@hcengineering/view' import view from '@hcengineering/view'
import { TreeElement } from '@hcengineering/view-resources' import { TreeElement } from '@hcengineering/view-resources'
@ -91,7 +97,7 @@
okLabel: presentation.string.CopyLink, okLabel: presentation.string.CopyLink,
canSubmit: false, canSubmit: false,
action: async () => { action: async () => {
await navigator.clipboard.writeText(link) await copyTextToClipboard(link)
} }
}, },
undefined undefined

View File

@ -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}

View File

@ -93,6 +93,7 @@
const _class = hierarchy.getClass(desc) const _class = hierarchy.getClass(desc)
if (_class.label === undefined) continue if (_class.label === undefined) continue
if (_class.kind !== ClassifierKind.CLASS) continue if (_class.kind !== ClassifierKind.CLASS) continue
if ((_class as MasterTag).removed === true) continue
added.add(desc) added.add(desc)
toAdd.push(_class) toAdd.push(_class)
} }

View File

@ -83,7 +83,6 @@
preferenceQuery.query( preferenceQuery.query(
view.class.ViewletPreference, view.class.ViewletPreference,
{ {
space: core.space.Workspace,
attachedTo: viewletId attachedTo: viewletId
}, },
(res) => { (res) => {
@ -129,7 +128,7 @@
const filledData = fillDefaults(hierarchy, data, object._class) 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) Analytics.handleEvent(CardEvents.CardCreated)

View File

@ -35,7 +35,13 @@
const desc = hierarchy.getDescendants(_class) const desc = hierarchy.getDescendants(_class)
for (const clazz of desc) { for (const clazz of desc) {
const cls = hierarchy.getClass(clazz) 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) result.push(clazz)
} }
} }

View File

@ -24,12 +24,7 @@ import aiBot from '@hcengineering/ai-bot'
import { summarizeMessages as aiSummarizeMessages, translate as aiTranslate } from '@hcengineering/ai-bot-resources' 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 { type Channel, type ChatMessage, type DirectMessage, type ThreadMessage } from '@hcengineering/chunter'
import contact, { getCurrentEmployee, getName, type Employee, type Person } from '@hcengineering/contact' import contact, { getCurrentEmployee, getName, type Employee, type Person } from '@hcengineering/contact'
import { import { employeeByAccountStore, employeeByIdStore, PersonIcon } from '@hcengineering/contact-resources'
employeeByAccountStore,
employeeByIdStore,
PersonIcon,
personRefByAccountUuidStore
} from '@hcengineering/contact-resources'
import core, { import core, {
getCurrentAccount, getCurrentAccount,
notEmpty, 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 client = getClient()
const me = getCurrentEmployee() const me = getCurrentEmployee()
const myAcc = getCurrentAccount() const myAcc = getCurrentAccount()
const existingDms = await client.findAll(chunter.class.DirectMessage, {}) const existingDms = await client.findAll(chunter.class.DirectMessage, {})
const newDirectPersons = employeeIds.includes(me) ? employeeIds : [...employeeIds, me] const newDirectEmployeeIds = Array.from(new Set([...employeeIds, me]))
const newPersonsSet = new Set(newDirectPersons)
let direct: DirectMessage | undefined let direct: DirectMessage | undefined
const personRefByAccountUuid = get(personRefByAccountUuidStore)
const employeeById = get(employeeByIdStore) const employeeById = get(employeeByIdStore)
const newDirectAccounts = new Set(newDirectEmployeeIds.map((it) => employeeById.get(it)?.personUuid).filter(notEmpty))
for (const dm of existingDms) { 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 continue
} }
let match = true let match = true
for (const person of existPersonsSet) { for (const acc of existAccounts) {
if (!newPersonsSet.has(person as Ref<Person>)) { if (!newDirectAccounts.has(acc)) {
match = false match = false
break break
} }
@ -603,15 +598,7 @@ export async function createDirect (employeeIds: Array<Ref<Person>>): Promise<Re
description: '', description: '',
private: true, private: true,
archived: false, archived: false,
members: newDirectPersons.map((person) => { members: Array.from(newDirectAccounts)
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
})
})) }))
const context = await client.findOne(notification.class.DocNotifyContext, { const context = await client.findOne(notification.class.DocNotifyContext, {

View File

@ -298,7 +298,7 @@ class Connection implements ClientConnection {
return return
} }
if (resp.rateLimit !== undefined && resp.rateLimit.remaining < 10) { if (resp.rateLimit !== undefined && resp.rateLimit.remaining < 50) {
console.log( console.log(
'Rate limits:', 'Rate limits:',
resp.rateLimit.remaining, resp.rateLimit.remaining,

View File

@ -45,6 +45,8 @@
import { getProjectDocumentLink } from '../navigation' import { getProjectDocumentLink } from '../navigation'
import documentRes from '../plugin' import documentRes from '../plugin'
import { import {
$approvalRequest as approvalRequest,
$reviewRequest as reviewRequest,
$activeRightPanelTab as activeRightPanelTab, $activeRightPanelTab as activeRightPanelTab,
$availableEditorModes as availableEditorModes, $availableEditorModes as availableEditorModes,
$availableRightPanelTabs as availableRightPanelTabs, $availableRightPanelTabs as availableRightPanelTabs,
@ -140,7 +142,12 @@
{ {
label: documentRes.string.TeamTab, label: documentRes.string.TeamTab,
component: EditDocTeam, component: EditDocTeam,
props: { controlledDoc: $controlledDocument, editable: $isDocumentOwner } props: {
controlledDoc: $controlledDocument,
editable: $isDocumentOwner,
reviewRequest: $reviewRequest,
approvalRequest: $approvalRequest
}
}, },
{ {
label: documentRes.string.ReleaseTab, label: documentRes.string.ReleaseTab,

View File

@ -25,14 +25,13 @@
export let canChangeReviewers: boolean = true export let canChangeReviewers: boolean = true
export let canChangeApprovers: boolean = true export let canChangeApprovers: boolean = true
export let canChangeCoAuthors: 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 dispatch = createEventDispatcher()
const currentEmployee = getCurrentEmployee() const currentEmployee = getCurrentEmployee()
$: reviewers = controlledDoc.reviewers
$: approvers = controlledDoc.approvers
$: coAuthors = controlledDoc.coAuthors
$: permissionsSpace = space === documents.space.UnsortedTemplates ? documents.space.QualityDocuments : space $: permissionsSpace = space === documents.space.UnsortedTemplates ? documents.space.QualityDocuments : space
function getPermittedPersons ( function getPermittedPersons (

View File

@ -13,37 +13,92 @@
// limitations under the License. // limitations under the License.
--> -->
<script lang="ts"> <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 { Ref } from '@hcengineering/core'
import { ControlledDocument, ControlledDocumentState, DocumentState } from '@hcengineering/controlled-documents'
import { Employee } from '@hcengineering/contact'
import { getClient } from '@hcengineering/presentation' import { getClient } from '@hcengineering/presentation'
import { Scroller } from '@hcengineering/ui'
import DocTeam from './DocTeam.svelte' import DocTeam from './DocTeam.svelte'
export let controlledDoc: ControlledDocument export let controlledDoc: ControlledDocument
export let editable: boolean = true export let editable: boolean = true
export let reviewRequest: DocumentReviewRequest | undefined
export let approvalRequest: DocumentApprovalRequest | undefined
$: canChangeCoAuthors = $: controlledState = controlledDoc.controlledState ?? null
editable && controlledDoc.state === DocumentState.Draft && controlledDoc.controlledState == null
$: canChangeReviewers = $: isEditableDraft = editable && controlledDoc.state === DocumentState.Draft
editable && controlledDoc.state === DocumentState.Draft && controlledDoc.controlledState == null
$: canChangeApprovers = $: inCleanState = controlledState === null
editable && $: inReview = controlledState === ControlledDocumentState.InReview && reviewRequest !== undefined
((controlledDoc.state === DocumentState.Draft && controlledDoc.controlledState == null) || $: inApproval = controlledState === ControlledDocumentState.InApproval && approvalRequest !== undefined
controlledDoc.controlledState === ControlledDocumentState.InReview || $: isReviewed = controlledState === ControlledDocumentState.Reviewed
controlledDoc.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() const client = getClient()
async function handleUpdate ({ async function handleUpdate ({
detail detail
}: { }: {
detail: { type: 'reviewers' | 'approvers', users: Ref<Employee>[] } detail: { type: 'reviewers' | 'approvers', users: Ref<Person>[] }
}): Promise<void> { }): Promise<void> {
const { type, users } = detail 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> </script>
@ -56,6 +111,9 @@
{canChangeCoAuthors} {canChangeCoAuthors}
{canChangeReviewers} {canChangeReviewers}
{canChangeApprovers} {canChangeApprovers}
{reviewers}
{approvers}
{coAuthors}
on:update={handleUpdate} on:update={handleUpdate}
/> />
</div> </div>

View File

@ -18,7 +18,8 @@ import {
type ControlledDocument, type ControlledDocument,
type DocumentComment, type DocumentComment,
type DocumentTraining, type DocumentTraining,
type Project type Project,
type DocumentTemplate
} from '@hcengineering/controlled-documents' } from '@hcengineering/controlled-documents'
import attachment from '@hcengineering/attachment' import attachment from '@hcengineering/attachment'
import { type Class, type DocumentQuery, type Ref, SortingOrder } from '@hcengineering/core' import { type Class, type DocumentQuery, type Ref, SortingOrder } from '@hcengineering/core'
@ -94,7 +95,7 @@ const queryDocumentVersionsFx = createEffect((payload: ControlledDocument) => {
documents.class.ControlledDocument, documents.class.ControlledDocument,
{ {
seqNumber: payload.seqNumber, seqNumber: payload.seqNumber,
template: payload.template template: (payload.template ?? null) as Ref<DocumentTemplate>
}, },
(result) => { (result) => {
documentAllVersionsUpdated(result ?? []) documentAllVersionsUpdated(result ?? [])

View File

@ -3,7 +3,7 @@ import { connectMeeting, disconnectMeeting } from '@hcengineering/ai-bot-resourc
import { Analytics } from '@hcengineering/analytics' import { Analytics } from '@hcengineering/analytics'
import calendar, { type Event, type Schedule, getAllEvents } from '@hcengineering/calendar' import calendar, { type Event, type Schedule, getAllEvents } from '@hcengineering/calendar'
import chunter from '@hcengineering/chunter' 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 { personByIdStore } from '@hcengineering/contact-resources'
import core, { import core, {
AccountRole, AccountRole,
@ -956,7 +956,7 @@ export async function tryConnect (
await client.update(invite, { status: invite.room === room._id ? RequestStatus.Approved : RequestStatus.Rejected }) 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)) { if ((room.access === RoomAccess.Knock || isGuest) && (!isOffice(room) || room.person !== currentPerson._id)) {
const _id = await client.createDoc(love.class.JoinRequest, core.space.Workspace, { const _id = await client.createDoc(love.class.JoinRequest, core.space.Workspace, {
person: currentPerson._id, person: currentPerson._id,

View File

@ -8,6 +8,7 @@
import WithTeamData from '../WithTeamData.svelte' import WithTeamData from '../WithTeamData.svelte'
import { toSlots } from '../utils' import { toSlots } from '../utils'
import DayPlan from './DayPlan.svelte' import DayPlan from './DayPlan.svelte'
import { personRefByAccountUuidStore } from '@hcengineering/contact-resources'
export let space: Ref<Project> export let space: Ref<Project>
export let currentDate: Date export let currentDate: Date
@ -36,9 +37,22 @@
todayFrom, todayFrom,
todayTo todayTo
) )
$: persons = (project?.members ?? [])
.map((it) => $personRefByAccountUuidStore.get(it))
.filter((it) => it !== undefined)
</script> </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 /> <Header bind:currentDate />
{#if project} {#if project}

View File

@ -43,7 +43,9 @@
{/each} {/each}
{#each gitem.events as event} {#each gitem.events as event}
{#if event.overlap === 0}
<EventItem item={event} showTime={expanded} /> <EventItem item={event} showTime={expanded} />
{/if}
{/each} {/each}
{#if gitem.busy.slots.length > 0} {#if gitem.busy.slots.length > 0}

View File

@ -16,8 +16,12 @@
import calendar, { Event, getAllEvents } from '@hcengineering/calendar' import calendar, { Event, getAllEvents } from '@hcengineering/calendar'
import { calendarByIdStore } from '@hcengineering/calendar-resources' import { calendarByIdStore } from '@hcengineering/calendar-resources'
import { getCurrentEmployee, Person } from '@hcengineering/contact' import { getCurrentEmployee, Person } from '@hcengineering/contact'
import { socialIdsByPersonRefStore, personRefByPersonIdStore } from '@hcengineering/contact-resources' import {
import core, { Doc, IdMap, Ref, Timestamp, Tx, TxCUD, TxCreateDoc, TxUpdateDoc } from '@hcengineering/core' 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 { Asset } from '@hcengineering/platform'
import { createQuery, getClient } from '@hcengineering/presentation' import { createQuery, getClient } from '@hcengineering/presentation'
import { Project } from '@hcengineering/task' import { Project } from '@hcengineering/task'
@ -43,7 +47,10 @@
let slots: WorkSlot[] = [] let slots: WorkSlot[] = []
let events: Event[] = [] let events: Event[] = []
let todos: IdMap<ToDo> = new Map() 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() const txCreateQuery = createQuery()

View File

@ -23,6 +23,7 @@
import { groupTeamData, toSlots } from '../utils' import { groupTeamData, toSlots } from '../utils'
import EventElement from './EventElement.svelte' import EventElement from './EventElement.svelte'
import PersonCalendar from './PersonCalendar.svelte' import PersonCalendar from './PersonCalendar.svelte'
import { personRefByAccountUuidStore } from '@hcengineering/contact-resources'
export let space: Ref<Project> export let space: Ref<Project>
export let currentDate: Date export let currentDate: Date
@ -37,7 +38,10 @@
let slots: WorkSlot[] = [] let slots: WorkSlot[] = []
let events: Event[] = [] let events: Event[] = []
let todos: IdMap<ToDo> = new Map() 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[] { function calcHourWidth (events: Event[], totalWidth: number): number[] {
const hours = new Map<number, number>() const hours = new Map<number, number>()

View File

@ -173,10 +173,10 @@ function crossWith (a: EventVars, b: EventVars): EventVars[] {
/** /**
* *
* @param events - without overlaps * @param events - without overlaps
* @param event - * @param event - any object with date and dueDate properties
* @returns * @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 }] let tmp: EventVars[] = [{ date: event.date, dueDate: event.dueDate }]
for (const a of events) { for (const a of events) {
const newTmp: EventVars[] = [] const newTmp: EventVars[] = []

View File

@ -162,11 +162,11 @@ async function getUpdateText (update: ActivityUpdate, card: Card, hierarchy: Hie
const clazz = hierarchy.getClass(update.tag) const clazz = hierarchy.getClass(update.tag)
if (update.action === 'add') { if (update.action === 'add') {
const tagName = await translate(clazz.label, {}) 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') { if (update.action === 'remove') {
const tagName = await translate(clazz.label, {}) 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 return undefined

View File

@ -1401,7 +1401,8 @@ class MongoAdapter extends MongoAdapterBase {
const filter = { _id: tx.objectId } const filter = { _id: tx.objectId }
const modifyOp = { const modifyOp = {
modifiedBy: tx.modifiedBy, modifiedBy: tx.modifiedBy,
modifiedOn: tx.modifiedOn modifiedOn: tx.modifiedOn,
'%hash%': this.curHash()
} }
if (isOperator(tx.attributes)) { if (isOperator(tx.attributes)) {
const update = { ...this.translateMixinAttrs(tx.mixin, tx.attributes), $set: { ...modifyOp } } const update = { ...this.translateMixinAttrs(tx.mixin, tx.attributes), $set: { ...modifyOp } }

View File

@ -40,15 +40,13 @@ export class SlidingWindowRateLimitter {
// Update reset time // Update reset time
rateLimit.resetTime = now + this.rateLimitWindow rateLimit.resetTime = now + this.rateLimitWindow
if (rateLimit.requests.length <= this.rateLimitMax) {
rateLimit.requests.push(now + (rateLimit.rejectedRequests > this.rateLimitMax * 2 ? this.rateLimitWindow * 5 : 0)) rateLimit.requests.push(now + (rateLimit.rejectedRequests > this.rateLimitMax * 2 ? this.rateLimitWindow * 5 : 0))
}
if (rateLimit.requests.length > this.rateLimitMax) { if (rateLimit.requests.length > this.rateLimitMax) {
rateLimit.rejectedRequests++ 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 // Find when the oldest request will exit the window
const someRequest = Math.round(Math.random() * rateLimit.requests.length) const someRequest = Math.round(Math.random() * rateLimit.requests.length)
const nextAvailableTime = rateLimit.requests[someRequest] + this.rateLimitWindow const nextAvailableTime = rateLimit.requests[someRequest] + this.rateLimitWindow

View File

@ -123,7 +123,6 @@ describe('SlidingWindowRateLimitter', () => {
clock += 10000 clock += 10000
const r2 = limiter.checkRateLimit('user1') const r2 = limiter.checkRateLimit('user1')
expect(r2.remaining).toBe(0) expect(r2.remaining).toBe(9)
expect(r2.retryAfter).toBeDefined()
}) })
}) })

View File

@ -88,6 +88,16 @@ export class SyncManager {
await this.keyValueClient.setValue(historyKey, history) 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> { private async partSync (userId: PersonId, userEmail: string | undefined, historyId: string): Promise<void> {
if (userEmail === undefined) { if (userEmail === undefined) {
throw new Error('Cannot sync without user email') throw new Error('Cannot sync without user email')
@ -151,7 +161,10 @@ export class SyncManager {
if (userEmail === undefined) { if (userEmail === undefined) {
throw new Error('Cannot sync without user email') 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 = { const query: gmail_v1.Params$Resource$Users$Messages$List = {
userId: 'me', userId: 'me',
pageToken pageToken
@ -160,49 +173,57 @@ export class SyncManager {
query.q = q query.q = q
} }
let currentHistoryId: string | undefined let currentHistoryId: string | undefined
let totalProcessedMessages = 0
try { try {
const messagesIds: string[] = [] // Process one page at a time
while (true) { while (true) {
await this.rateLimiter.take(5) await this.rateLimiter.take(5)
const messages = await this.gmail.messages.list(query) const messages = await this.gmail.messages.list(query)
if (query.pageToken == null) {
this.ctx.info('Total messages', { const ids = messages.data.messages?.map((p) => p.id).filter((id) => id != null) ?? []
this.ctx.info('Processing page', {
workspace: this.workspace, workspace: this.workspace,
userId, userId,
resultSizeEstimate: messages.data.resultSizeEstimate messagesInPage: ids.length,
totalProcessed: totalProcessedMessages,
currentHistoryId,
pageToken: query.pageToken
}) })
}
const ids = messages.data.messages?.map((p) => p.id) ?? [] for (const id of ids) {
for (let index = 0; index < ids.length; index++) {
const id = ids[index]
if (id == null) continue if (id == null) continue
messagesIds.push(id)
}
if (messages.data.nextPageToken == null) {
this.ctx.info('Break', { totalNewMessages: messagesIds.length })
break
}
query.pageToken = messages.data.nextPageToken
}
for (let index = messagesIds.length - 1; index >= 0; index--) {
const id = messagesIds[index]
try { try {
const message = await this.getMessage(id) const message = await this.getMessage(id)
const historyId = message.data.historyId const historyId = message.data.historyId
await this.messageManager.saveMessage(message, userEmail) await this.messageManager.saveMessage(message, userEmail)
if (historyId != null && q === undefined) { if (historyId != null && q === undefined) {
if (currentHistoryId == null || Number(currentHistoryId) < Number(historyId)) { if (currentHistoryId == null || Number(currentHistoryId) < Number(historyId)) {
await this.setHistoryId(userId, historyId)
currentHistoryId = historyId currentHistoryId = historyId
} }
} }
if (index % 500 === 0) {
this.ctx.info('Remaining messages to sync', { workspace: this.workspace, userId, count: index })
}
} catch (err: any) { } catch (err: any) {
this.ctx.error('Full sync message error', { workspace: this.workspace, userId, err }) 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('Completed sync', { workspace: this.workspace, userId, totalMessages: totalProcessedMessages })
break
}
// Update page token for the next iteration
pageToken = messages.data.nextPageToken
query.pageToken = pageToken
await this.setPageToken(userId, pageToken)
}
if (currentHistoryId != null) {
await this.setHistoryId(userId, currentHistoryId)
}
this.ctx.info('Full sync finished', { workspaceUuid: this.workspace, userId, userEmail }) this.ctx.info('Full sync finished', { workspaceUuid: this.workspace, userId, userEmail })
} catch (err) { } catch (err) {
this.ctx.error('Full sync error', { workspace: this.workspace, userId, err }) this.ctx.error('Full sync error', { workspace: this.workspace, userId, err })
@ -243,4 +264,8 @@ export class SyncManager {
private getHistoryKey (userId: PersonId): string { private getHistoryKey (userId: PersonId): string {
return `history:${this.workspace}:${userId}` return `history:${this.workspace}:${userId}`
} }
private getPageTokenKey (userId: PersonId): string {
return `page-token:${this.workspace}:${userId}`
}
} }