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.
//
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 })
}

View File

@ -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
}
])
},

View File

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

View File

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

View File

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

View File

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

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;
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,

View File

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

View File

@ -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,14 +24,16 @@
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'}
{#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} />
@ -40,7 +42,7 @@
<Label label={mixin.label} />
</div>
</div>
{:else if update.action === 'remove'}
{:else if update.action === 'remove'}
<div class="flex-presenter overflow-label flex-gap-2">
<Icon icon={IconDelete} size="small" />
<Label label={uiNext.string.Removed} />
@ -49,6 +51,16 @@
<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}
<style lang="scss">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -53,6 +53,8 @@
"UpdatedObject": "更新 {object}",
"NewObjectType": "新 {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,
NewObjectType: '' as IntlString,
RemovedObjectType: '' as IntlString,
AttributeSetTo: '' as IntlString
AttributeSetTo: '' as IntlString,
AddedTag: '' as IntlString,
RemovedTag: '' as IntlString
},
component: {
Activity: '' as AnyComponent,

View File

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

View File

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

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)
if (_class.label === undefined) continue
if (_class.kind !== ClassifierKind.CLASS) continue
if ((_class as MasterTag).removed === true) continue
added.add(desc)
toAdd.push(_class)
}

View File

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

View File

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

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 { 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, {

View File

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

View File

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

View File

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

View File

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

View File

@ -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 ?? [])

View File

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

View File

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

View File

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

View File

@ -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()

View File

@ -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>()

View File

@ -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[] = []

View File

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

View File

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

View File

@ -40,15 +40,13 @@ export class SlidingWindowRateLimitter {
// Update reset time
rateLimit.resetTime = now + this.rateLimitWindow
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

View File

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

View File

@ -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,49 +173,57 @@ 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', {
const ids = messages.data.messages?.map((p) => p.id).filter((id) => id != null) ?? []
this.ctx.info('Processing page', {
workspace: this.workspace,
userId,
resultSizeEstimate: messages.data.resultSizeEstimate
messagesInPage: ids.length,
totalProcessed: totalProcessedMessages,
currentHistoryId,
pageToken: query.pageToken
})
}
const ids = messages.data.messages?.map((p) => p.id) ?? []
for (let index = 0; index < ids.length; index++) {
const id = ids[index]
for (const id of ids) {
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 {
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 })
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 })
} catch (err) {
this.ctx.error('Full sync error', { workspace: this.workspace, userId, 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}`
}
}