Various fixes (#2248)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-08-08 13:54:36 +07:00 committed by GitHub
parent ae09613d5f
commit 122cf92165
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1050 additions and 82 deletions

View File

@ -112,6 +112,7 @@
"csv-parse": "~5.1.0", "csv-parse": "~5.1.0",
"@anticrm/lead": "~0.6.0", "@anticrm/lead": "~0.6.0",
"email-addresses": "^5.0.0", "email-addresses": "^5.0.0",
"libphonenumber-js": "^1.9.46" "libphonenumber-js": "^1.9.46",
"@anticrm/setting": "~0.6.1"
} }
} }

View File

@ -23,7 +23,7 @@ import { updateClasses } from './classes'
import { CustomCustomer, FieldType } from './types' import { CustomCustomer, FieldType } from './types'
import { filled } from './utils' import { filled } from './utils'
import { parse } from 'csv-parse' import { parseCSV } from './parseCSV'
const names = { const names = {
orgName: 'Название компании', orgName: 'Название компании',
@ -125,31 +125,6 @@ async function updateStates<T extends State | DoneState> (
} }
} }
export async function parseCSV (csvData: string): Promise<any[]> {
return await new Promise((resolve, reject) => {
parse(
csvData,
{
delimiter: ';',
columns: true,
quote: '"',
bom: true,
cast: true,
autoParse: true,
castDate: false,
skipEmptyLines: true,
skipRecordsWithEmptyValues: true
},
(err, records) => {
if (err !== undefined) {
console.error(err)
reject(err)
}
resolve(records)
}
)
})
}
export async function importLead (transactorUrl: string, dbName: string, csvFile: string): Promise<void> { export async function importLead (transactorUrl: string, dbName: string, csvFile: string): Promise<void> {
const connection = await connect(transactorUrl, dbName) const connection = await connect(transactorUrl, dbName)

View File

@ -0,0 +1,27 @@
import { parse } from 'csv-parse'
export async function parseCSV (csvData: string): Promise<any[]> {
return await new Promise((resolve, reject) => {
parse(
csvData,
{
delimiter: ';',
columns: true,
quote: '"',
bom: true,
cast: true,
autoParse: true,
castDate: false,
skipEmptyLines: true,
skipRecordsWithEmptyValues: true
},
(err, records) => {
if (err !== undefined) {
console.error(err)
reject(err)
}
resolve(records)
}
)
})
}

View File

@ -0,0 +1,760 @@
//
// Copyright © 2022 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 contact, { ChannelProvider, combineName, Contact, EmployeeAccount, Person } from '@anticrm/contact'
import core, {
AnyAttribute,
BackupClient,
BlobData,
Class,
ClassifierKind,
Client,
Data,
Doc,
DOMAIN_BLOB,
Enum,
EnumOf,
generateId,
Ref,
Timestamp,
TxOperations,
WithLookup
} from '@anticrm/core'
import { Asset, getEmbeddedLabel, IntlString } from '@anticrm/platform'
import recruit, { Candidate } from '@anticrm/recruit'
import { connect } from '@anticrm/server-tool'
import setting from '@anticrm/setting'
import { readFile } from 'fs/promises'
import { parseCSV } from './parseCSV'
import { FieldType } from './types'
import { filled } from './utils'
import got from 'got'
import mimetypes from 'mime-types'
import attachment, { Attachment } from '@anticrm/attachment'
import { generateToken } from '@anticrm/server-token'
import { recognize, updateContacts, updateSkills } from '../recruit'
import { ReconiDocument } from '@anticrm/rekoni'
import { findOrUpdateAttached } from '../utils'
const names = {
status: 'Status',
fullName: 'Lead Name',
firstName: 'First Name',
middleName: 'Middle Name',
lastName: 'Last Name',
birthDay: 'Date of birth',
created: 'Created',
kasource: 'Source',
workEmail: 'Work E-mail',
newsletterEmail: 'Newsletters email',
otherEmail: 'Other E-mail',
homeEmail: 'Home E-mail',
telegram: 'Telegram account',
vkAccount: 'VK account',
skypeID: 'Skype ID',
viberContact: 'Viber contact',
otherContact: 'Other Contact',
responsible: 'Responsible',
resume: 'Резюме',
profile: 'Ссылка на профайл',
liveCountry: 'Страна проживания',
phone: 'Телефон',
phoneNumber: 'Номер телефона',
githubPortfolio: 'Ссылка на гитхаб/портфолио',
webSite: 'Веб-сайт',
linkedRu1: 'Линкедин',
englishLevel: 'Уровень английского',
movedFromTrello: 'Полностью перенесен из Трелло',
bot: 'С кем общался (бот)',
research: 'Ресеч ',
region: 'Регион',
mainExperience: 'Опыт работы по основному стеку с года:',
mainStack: 'Основной стек (выпадающий список)',
extraStack: 'Доп.стек',
education: 'Образование',
relocationPreferences: 'Предпочтения по релокации',
relocationIgnore: 'Какую компанию не предлагать! (или страну)',
relocationWant: 'Какие страны/города рассматривает',
language: 'Язык общения',
languageExtra: 'Владение другими языками',
seed: 'Посев',
actualization: 'Актуализация (дата)',
unsubscribe: 'Отказался от рассылки',
companyName: 'Название компании (новое поле)'
}
const kaDetails = 'recruit:mixin:KADetails' as Ref<Class<Doc>>
const portfolioDetails = 'recruit:mixin:KAPortfolio' as Ref<Class<Doc>>
const techDetails = 'recruit:mixin:TechDetails' as Ref<Class<Doc>>
const locationDetails = 'recruit:mixin:CountryDetails' as Ref<Class<Doc>>
const fieldMapping: Record<string, FieldType> = {
[names.fullName]: {
name: 'fullName',
type: core.class.TypeString,
label: core.string.String,
sourceClass: contact.class.Person
},
[names.middleName]: {
name: 'middleName',
type: core.class.TypeString,
label: core.string.String,
sourceClass: contact.class.Person
},
Language: {
name: 'language',
type: core.class.EnumOf,
enumName: 'KAPrefferedLanguage',
label: core.string.Enum,
fName: names.language,
sourceClass: contact.class.Person
},
[names.languageExtra]: {
name: 'language_extra',
type: core.class.TypeString,
label: core.string.String,
sourceClass: contact.class.Person
},
[names.created]: { name: 'created', type: core.class.TypeDate, label: core.string.Date, sourceClass: kaDetails },
[names.status]: {
name: 'lead_status',
type: core.class.EnumOf,
enumName: 'KAStatus',
label: core.string.Enum,
sourceClass: kaDetails
},
[names.kasource]: {
name: 'kasource',
type: core.class.EnumOf,
enumName: 'KASource',
label: core.string.Enum,
fName: names.kasource,
sourceClass: kaDetails
},
[names.responsible]: {
name: 'responsible',
type: core.class.EnumOf,
enumName: 'KAResponsible',
label: core.string.Enum,
sourceClass: kaDetails
},
[names.movedFromTrello]: {
name: 'trello_moved',
type: core.class.TypeBoolean,
label: core.string.Boolean,
sourceClass: kaDetails
},
[names.bot]: {
name: 'bot',
type: core.class.EnumOf,
enumName: 'KABot',
label: core.string.Enum,
sourceClass: kaDetails
},
Ресерч: {
name: 'research',
type: core.class.EnumOf,
enumName: 'KAResearch',
label: core.string.Enum,
fName: names.research,
sourceClass: kaDetails
},
[names.seed]: {
name: 'seed',
type: core.class.EnumOf,
enumName: 'KASeed',
label: core.string.Enum,
sourceClass: kaDetails
},
[names.actualization]: {
name: 'actualization',
type: core.class.TypeDate,
label: core.string.Date,
sourceClass: kaDetails
},
'Unsubscribe / Отказ от рассылки': {
name: 'unsubscribe',
type: core.class.TypeBoolean,
label: core.string.Boolean,
sourceClass: kaDetails
},
[names.companyName]: {
name: 'company_name',
type: core.class.TypeString,
label: core.string.Boolean,
sourceClass: kaDetails
},
[names.resume]: {
name: 'resume',
type: core.class.TypeString,
label: core.string.String,
sourceClass: portfolioDetails
},
[names.profile]: {
name: 'profile_link',
type: core.class.TypeString,
label: core.string.String,
sourceClass: portfolioDetails
},
[names.githubPortfolio]: {
name: 'github_portfolio',
type: core.class.TypeString,
label: core.string.String,
sourceClass: portfolioDetails
},
[names.vkAccount]: {
name: 'vk_account',
type: core.class.TypeString,
label: core.string.String,
sourceClass: portfolioDetails
},
[names.viberContact]: {
name: 'viberContact',
type: core.class.TypeString,
label: core.string.String,
sourceClass: portfolioDetails
},
[names.otherContact]: {
name: 'otherContact',
type: core.class.TypeString,
label: core.string.String,
sourceClass: portfolioDetails
},
'Основной Стек': {
name: 'main_stack',
type: core.class.EnumOf,
enumName: 'KAMainTechStack',
label: core.string.Enum,
fName: names.mainStack,
sourceClass: techDetails
},
[names.englishLevel]: {
name: 'english_level',
type: core.class.EnumOf,
enumName: 'KAEnglishLevel',
label: core.string.Enum,
sourceClass: techDetails
},
[names.mainExperience]: {
name: 'main_experience',
type: core.class.TypeNumber,
label: core.string.Number,
sourceClass: techDetails
},
[names.extraStack]: {
name: 'extra_stack',
type: core.class.TypeString,
label: core.string.String,
sourceClass: techDetails
},
[names.education]: {
name: 'education',
type: core.class.TypeString,
label: core.string.String,
sourceClass: techDetails
},
Region: {
name: 'region',
type: core.class.EnumOf,
enumName: 'KARegion',
label: core.string.Enum,
fName: names.region,
sourceClass: locationDetails
},
[names.relocationPreferences]: {
name: 'relocation_preferences',
type: core.class.EnumOf,
enumName: 'KARelocationPreferences',
label: core.string.Enum,
sourceClass: locationDetails
},
[names.relocationIgnore]: {
name: 'relocation_ignore',
type: core.class.TypeString,
label: core.string.String,
sourceClass: locationDetails
},
[names.relocationWant]: {
name: 'relocation_want',
type: core.class.TypeString,
label: core.string.String,
sourceClass: locationDetails
}
}
function getAttr (client: TxOperations, _class: Ref<Class<Doc>>, name: string): AnyAttribute | undefined {
try {
return client.getHierarchy().getAttribute(_class, name)
} catch {
return undefined
}
}
export async function updateTalantClasses (
client: TxOperations,
records: any[],
fieldMapping: Record<string, FieldType>,
refClass: Ref<Class<Doc>> = recruit.mixin.Candidate
): Promise<void> {
const allAttrs = client.getHierarchy().getAllAttributes(refClass)
for (const [k, v] of Object.entries(fieldMapping)) {
if (v.type === undefined) {
continue
}
let attr = v.sourceClass === undefined ? allAttrs.get(v.name) : getAttr(client, v.sourceClass, v.name)
if (attr === undefined) {
try {
if (!client.getHierarchy().isDerived(v.type, core.class.Type)) {
// Skip channels mapping
continue
}
} catch (any) {
continue
}
// Create attr
const data: Data<AnyAttribute> = {
attributeOf: v.sourceClass ?? refClass,
name: v.name,
label: getEmbeddedLabel(k),
isCustom: true,
type: {
_class: v.type,
label: v.label ?? core.string.String
}
}
if (client.getHierarchy().isDerived(v.type, core.class.EnumOf)) {
;(data.type as EnumOf).of = `recruit:class:${(v as any).enumName as string}` as Ref<Enum>
}
const attrId = ((v.sourceClass ?? refClass) + '.' + v.name) as Ref<AnyAttribute>
await client.createDoc(core.class.Attribute, core.space.Model, data, attrId)
attr = await client.findOne(core.class.Attribute, { _id: attrId })
}
if (attr === undefined) {
continue
}
// Check update Enum/Values
if (client.getHierarchy().isDerived(attr.type._class, core.class.EnumOf)) {
const enumName = (v as any).enumName as string
const enumId = `recruit:class:${enumName}` as Ref<Enum>
let enumClass = await client.findOne(core.class.Enum, { _id: enumId })
if (enumClass === undefined) {
await client.createDoc(
core.class.Enum,
core.space.Model,
{
name: enumName,
enumValues: []
},
enumId
)
enumClass = client.getModel().getObject(enumId)
}
// Check values
const mapv = (v?: string): string =>
(v?.toString() ?? '').trim().length === 0 ? 'не задано' : (v?.toString() ?? '').trim()
const values = records
.map((it) => it[v.fName ?? k])
.map(mapv)
.filter((it, idx, arr) => arr.indexOf(it) === idx)
for (const v of values) {
if (!enumClass.enumValues.includes(v)) {
await client.update(enumClass, {
$push: { enumValues: v }
})
}
}
}
}
}
export async function importTalants (
transactorUrl: string,
dbName: string,
csvFile: string,
rekoniUrl: string
): Promise<void> {
const connection = (await connect(transactorUrl, dbName, undefined, {
mode: 'backup'
})) as unknown as Client & BackupClient
try {
console.log('loading cvs document...')
const csvData = await readFile(csvFile, 'utf-8')
const records: any[] = await parseCSV(csvData)
const uniqKeys: string[] = []
const filledFields = records.map((it) => filled(it, uniqKeys))
// console.log(filledFields)
const client = new TxOperations(connection, 'core:account:recruit-importer' as Ref<EmployeeAccount>)
await createMissingClass(client, kaDetails, getEmbeddedLabel('KA Details'))
await createMissingClass(client, portfolioDetails, getEmbeddedLabel('Portfolio'))
await createMissingClass(client, techDetails, getEmbeddedLabel('Technology'))
await createMissingClass(client, locationDetails, getEmbeddedLabel('Language & Relocations'))
await updateTalantClasses(client, records, fieldMapping)
await createTalants(client, filledFields, connection, rekoniUrl, generateToken('anticrm@hc.engineering', dbName))
} catch (err: any) {
console.error(err)
} finally {
await connection.close()
}
}
async function createMissingClass (
client: TxOperations,
ref: Ref<Class<Doc>>,
label: IntlString,
icon?: Asset
): Promise<void> {
const cl = await client.findOne(core.class.Class, { _id: ref })
if (cl === undefined) {
await client.createDoc(
core.class.Mixin,
core.space.Model,
{
extends: recruit.mixin.Candidate,
label,
kind: ClassifierKind.MIXIN,
icon
},
ref
)
await client.createMixin(ref, core.class.Mixin, core.space.Model, setting.mixin.UserMixin, {})
}
}
export interface KaDetailsTalant extends Candidate {
created: Timestamp
lead_status: any
kasource: any
responsible: any
trello_moved: boolean
bot: any
research: any
seed: any
actualization: Timestamp
unsubscribe: boolean
company_name: string
}
export interface PortfolioDetailsTalant extends Candidate {
resume: string
profile_link: string
github_portfolio: string
vk_account: string
viberContact: string
otherContact: string
}
export interface TechDetailsTalant extends Candidate {
main_stack: any
english_level: any
main_experience: number
extra_stack: string
education: string
}
export interface LocationDetailsTalant extends Candidate {
region: any
relocation_preferences: any
relocation_ignore: string
relocation_want: string
}
interface CustomPerson extends Person {
fullName: string
middleName: string
language?: string
language_extra?: string
}
async function createTalants (
client: TxOperations,
filledFields: any[],
connection: Client & BackupClient,
rekoniUrl: string,
token: string
): Promise<void> {
for (const record of filledFields) {
const candidateId = `imported-${record.ID as string}` as Ref<Candidate>
const leadName = record[names.fullName]
const candidate = await client.findOne(recruit.mixin.Candidate, { _id: candidateId })
console.log('processing', leadName)
const dataKa = {
created: new Date(record[names.created]).getTime(),
lead_status: record[names.status],
kasource: record[names.kasource],
responsible: record[names.responsible],
trello_moved: record[names.movedFromTrello] === 'yes',
bot: record[names.bot],
research: record[names.research],
seed: record[names.seed],
actualization: new Date(record[names.actualization]).getTime(),
unsubscribe: record[names.unsubscribe] === 'yes',
company_name: record[names.companyName]
}
const dataPortfolio = {
resume: record[names.resume],
profile_link: record[names.profile],
github_portfolio: record[names.githubPortfolio],
vk_account: record[names.vkAccount],
viberContact: record[names.viberContact],
otherContact: record[names.otherContact]
}
const dataTechStack = {
main_stack: record[names.mainStack],
english_level: record[names.englishLevel],
main_experience: record[names.mainExperience],
extra_stack: record[names.extraStack],
education: record[names.education]
}
const dataLocationDetails = {
region: record[names.region],
relocation_preferences: record[names.relocationPreferences],
relocation_ignore: record[names.relocationIgnore],
relocation_want: record[names.relocationWant]
}
if (candidate !== undefined) {
continue
}
const cdata = {
name: combineName(record[names.firstName], record[names.lastName]),
city: record[names.liveCountry],
birthday: record[names.birthDay] !== '' ? new Date(record[names.birthDay]).getTime() : undefined,
fullName: record[names.fullName],
middleName: record[names.middleName],
language: record[names.language],
language_extra: record[names.languageExtra]
}
await client.createDoc<CustomPerson>(
contact.class.Person,
contact.space.Contacts,
cdata,
candidateId as unknown as Ref<CustomPerson>
)
await client.createMixin<Contact, Candidate>(
candidateId,
contact.class.Person,
contact.space.Contacts,
recruit.mixin.Candidate,
{}
)
await client.createMixin<Contact, KaDetailsTalant>(
candidateId,
contact.class.Person,
contact.space.Contacts,
kaDetails,
dataKa
)
await client.createMixin<Contact, PortfolioDetailsTalant>(
candidateId,
contact.class.Person,
contact.space.Contacts,
portfolioDetails,
dataPortfolio
)
await client.createMixin<Contact, TechDetailsTalant>(
candidateId,
contact.class.Person,
contact.space.Contacts,
techDetails,
dataTechStack
)
await client.createMixin<Contact, LocationDetailsTalant>(
candidateId,
contact.class.Person,
contact.space.Contacts,
locationDetails,
dataLocationDetails
)
function getValid (...names: string[]): string | undefined {
for (const o of names) {
const v = record[o]
if (v !== undefined && typeof v === 'string' && v.trim().length > 0) {
return v
}
}
}
async function updateChannel (value: string | undefined, provider: Ref<ChannelProvider>): Promise<void> {
if (value === undefined) {
return
}
const channels = await client.findAll(contact.class.Channel, { attachedTo: candidateId })
const emailPr = channels.find((it) => it.value === value)
if (emailPr === undefined) {
await client.addCollection(
contact.class.Channel,
contact.space.Contacts,
candidateId,
contact.class.Person,
'channels',
{
value,
provider
}
)
}
}
await updateChannel(
getValid(record, names.workEmail, names.homeEmail, names.newsletterEmail, names.otherEmail),
contact.channelProvider.Email
)
await updateChannel(getValid(record, names.webSite), contact.channelProvider.Homepage)
await updateChannel(getValid(record, names.phone, names.phoneNumber), contact.channelProvider.Phone)
await updateChannel(getValid(record, names.telegram), contact.channelProvider.Telegram)
const ghval = getValid(record, names.githubPortfolio)
if (ghval?.includes('https://github.com') ?? false) {
await updateChannel(ghval, contact.channelProvider.GitHub)
}
const profile = getValid(record, names.profile)
if (profile?.includes('linkedin.com') ?? false) {
await updateChannel(profile, contact.channelProvider.LinkedIn)
}
const resume = record[names.resume] as string
if (resume !== undefined) {
const resumes = resume.split(',')
for (const r of resumes) {
try {
const url = (r ?? '').trim()
if (url.startsWith('http')) {
const lastpos = url.lastIndexOf('/')
const fname = url.substring(lastpos + 1)
const buffer = await got(url).buffer()
const blobId = (candidateId + '_' + generateId()) as Ref<BlobData>
const type = mimetypes.contentType(fname)
const data: BlobData = {
_id: blobId,
space: contact.space.Contacts,
modifiedBy: client.txFactory.account,
modifiedOn: Date.now(),
_class: core.class.BlobData,
name: fname,
size: buffer.length,
type: type !== false ? type : 'unknown',
base64Data: buffer.toString('base64')
}
await connection.upload(DOMAIN_BLOB, [data])
await client.addCollection(
attachment.class.Attachment,
contact.space.Contacts,
candidateId,
contact.class.Person,
'attachments',
{
file: blobId,
name: fname,
size: buffer.length,
type: type !== false ? type : 'unknown',
lastModified: Date.now()
}
)
const doc = await client.findOne(recruit.mixin.Candidate, { _id: candidateId })
if (doc === undefined) {
continue
}
if (type === 'application/pdf') {
const document = await recognize(rekoniUrl, data.base64Data, token)
if (document !== undefined) {
if (document.title !== undefined) {
await client.update(doc, { title: document.title })
}
await updateAvatar(doc, document, connection, client)
// Update contact
await updateContacts(client, doc, document)
// Update skills
await updateSkills(client, doc, document)
}
}
}
} catch (err) {
console.log(err)
}
}
}
}
}
async function updateAvatar (
c: WithLookup<Candidate>,
document: ReconiDocument,
connection: Client & BackupClient,
client: TxOperations
): Promise<void> {
if (document.format !== 'headhunter' && document.format !== 'podbor') {
// Only update avatar for this kind of resume formats.
return
}
if (
c.avatar === undefined &&
document.avatar !== undefined &&
document.avatarName !== undefined &&
document.avatarFormat !== undefined
) {
const attachId = `${c._id}.${document.avatarName}` as Ref<Attachment>
// Upload new avatar for candidate
const data = Buffer.from(document.avatar, 'base64')
const bdata: BlobData = {
_id: attachId as unknown as Ref<BlobData>,
space: contact.space.Contacts,
modifiedBy: client.txFactory.account,
modifiedOn: Date.now(),
_class: core.class.BlobData,
name: document.avatarName,
size: data.length,
type: document.avatarFormat,
base64Data: document.avatar
}
await connection.upload(DOMAIN_BLOB, [bdata])
await findOrUpdateAttached<Attachment>(
client,
contact.space.Contacts,
attachment.class.Photo,
attachId,
{
name: document.avatarName,
file: attachId,
type: document.avatarFormat,
size: data.length,
lastModified: Date.now()
},
{
attachedTo: c._id,
attachedClass: contact.class.Person,
collection: 'photos'
}
)
await client.update(c, { avatar: attachId })
}
}

View File

@ -38,6 +38,7 @@ export interface FieldType {
name: string name: string
type?: Ref<Class<Doc>> type?: Ref<Class<Doc>>
label?: IntlString label?: IntlString
sourceClass?: Ref<Class<Doc>>
enumName?: string enumName?: string
fName?: string fName?: string
} }

View File

@ -36,11 +36,12 @@ import toolPlugin, { prepareTools, version } from '@anticrm/server-tool'
import { program } from 'commander' import { program } from 'commander'
import { Db, MongoClient } from 'mongodb' import { Db, MongoClient } from 'mongodb'
import { exit } from 'process' import { exit } from 'process'
import { removeDuplicates } from './csv/duplicates'
import { importLead } from './csv/lead-importer'
import { importLead2 } from './csv/lead-importer2'
import { importTalants } from './csv/talant-importer'
import { rebuildElastic } from './elastic' import { rebuildElastic } from './elastic'
import { importXml } from './importer' import { importXml } from './importer'
import { removeDuplicates } from './leads/duplicates'
import { importLead } from './leads/lead-importer'
import { importLead2 } from './leads/lead-importer2'
import { updateCandidates } from './recruit' import { updateCandidates } from './recruit'
import { clearTelegramHistory } from './telegram' import { clearTelegramHistory } from './telegram'
import { diffWorkspace, dumpWorkspace, restoreWorkspace } from './workspace' import { diffWorkspace, dumpWorkspace, restoreWorkspace } from './workspace'
@ -374,6 +375,18 @@ program
return await importLead2(transactorUrl, workspace, fileName) return await importLead2(transactorUrl, workspace, fileName)
}) })
program
.command('import-talant-csv <workspace> <fileName>')
.description('Import Talant csv')
.action(async (workspace, fileName, cmd) => {
const rekoniUrl = process.env.REKONI_URL
if (rekoniUrl === undefined) {
console.log('Please provide REKONI_URL environment variable')
exit(1)
}
return await importTalants(transactorUrl, workspace, fileName, rekoniUrl)
})
program program
.command('lead-duplicates <workspace>') .command('lead-duplicates <workspace>')
.description('Find and remove duplicate organizations.') .description('Find and remove duplicate organizations.')

View File

@ -30,7 +30,7 @@ import { ElasticTool } from './elastic'
import { findOrUpdateAttached } from './utils' import { findOrUpdateAttached } from './utils'
import { readMinioData } from './workspace' import { readMinioData } from './workspace'
async function recognize (rekoniUrl: string, data: string, token: string): Promise<ReconiDocument | undefined> { export async function recognize (rekoniUrl: string, data: string, token: string): Promise<ReconiDocument | undefined> {
const { body }: { body?: ReconiDocument } = await got.post(rekoniUrl + '/recognize?format=pdf', { const { body }: { body?: ReconiDocument } = await got.post(rekoniUrl + '/recognize?format=pdf', {
headers: { headers: {
Authorization: 'Bearer ' + token, Authorization: 'Bearer ' + token,
@ -45,14 +45,14 @@ async function recognize (rekoniUrl: string, data: string, token: string): Promi
return body return body
} }
function isUndef (value?: string): boolean { export function isUndef (value?: string): boolean {
if (value == null || value.trim().length === 0) { if (value == null || value.trim().length === 0) {
return true return true
} }
return false return false
} }
async function addChannel ( export async function addChannel (
client: TxOperations, client: TxOperations,
channels: Channel[], channels: Channel[],
c: Candidate, c: Candidate,
@ -149,7 +149,7 @@ export async function updateCandidates (
} }
} }
async function updateSkills (client: TxOperations, c: Candidate, document: ReconiDocument): Promise<void> { export async function updateSkills (client: TxOperations, c: Candidate, document: ReconiDocument): Promise<void> {
const skills = await client.findAll(tags.class.TagReference, { attachedTo: c._id }) const skills = await client.findAll(tags.class.TagReference, { attachedTo: c._id })
const namedSkills = new Set(Array.from(skills.map((it) => it.title.toLowerCase()))) const namedSkills = new Set(Array.from(skills.map((it) => it.title.toLowerCase())))
@ -185,7 +185,11 @@ async function updateSkills (client: TxOperations, c: Candidate, document: Recon
} }
} }
} }
async function updateContacts (client: TxOperations, c: WithLookup<Candidate>, document: ReconiDocument): Promise<void> { export async function updateContacts (
client: TxOperations,
c: WithLookup<Candidate>,
document: ReconiDocument
): Promise<void> {
const channels = await client.findAll(contact.class.Channel, { attachedTo: c._id }) const channels = await client.findAll(contact.class.Channel, { attachedTo: c._id })
await addChannel(client, channels, c, contact.channelProvider.Email, document.email) await addChannel(client, channels, c, contact.channelProvider.Email, document.email)
await addChannel(client, channels, c, contact.channelProvider.GitHub, document.github) await addChannel(client, channels, c, contact.channelProvider.GitHub, document.github)

View File

@ -29,7 +29,18 @@ import {
} from '@anticrm/contact' } from '@anticrm/contact'
import type { Class, Domain, Ref, Timestamp } from '@anticrm/core' import type { Class, Domain, Ref, Timestamp } from '@anticrm/core'
import { DOMAIN_MODEL, IndexKind } from '@anticrm/core' import { DOMAIN_MODEL, IndexKind } from '@anticrm/core'
import { Builder, Collection, Index, Model, Prop, TypeRef, TypeString, TypeTimestamp, UX } from '@anticrm/model' import {
Builder,
Collection,
Index,
Model,
Prop,
TypeDate,
TypeRef,
TypeString,
TypeTimestamp,
UX
} from '@anticrm/model'
import attachment from '@anticrm/model-attachment' import attachment from '@anticrm/model-attachment'
import chunter from '@anticrm/model-chunter' import chunter from '@anticrm/model-chunter'
import core, { TAccount, TAttachedDoc, TDoc, TSpace } from '@anticrm/model-core' import core, { TAccount, TAttachedDoc, TDoc, TSpace } from '@anticrm/model-core'
@ -94,7 +105,10 @@ export class TChannel extends TAttachedDoc implements Channel {
@Model(contact.class.Person, contact.class.Contact) @Model(contact.class.Person, contact.class.Contact)
@UX(contact.string.Person, contact.icon.Person, undefined, 'name') @UX(contact.string.Person, contact.icon.Person, undefined, 'name')
export class TPerson extends TContact implements Person {} export class TPerson extends TContact implements Person {
@Prop(TypeDate(), contact.string.Birthday)
birthday?: Timestamp
}
@Model(contact.class.Member, core.class.AttachedDoc, DOMAIN_CONTACT) @Model(contact.class.Member, core.class.AttachedDoc, DOMAIN_CONTACT)
@UX(contact.string.Member, contact.icon.Person, undefined, 'name') @UX(contact.string.Member, contact.icon.Person, undefined, 'name')

View File

@ -65,7 +65,8 @@ export default mergeIds(contactId, contact, {
GitHub: '' as IntlString, GitHub: '' as IntlString,
Facebook: '' as IntlString, Facebook: '' as IntlString,
TypeLabel: '' as IntlString, TypeLabel: '' as IntlString,
Homepage: '' as IntlString Homepage: '' as IntlString,
Birthday: '' as IntlString
}, },
completion: { completion: {
PersonQuery: '' as Resource<ObjectSearchFactory>, PersonQuery: '' as Resource<ObjectSearchFactory>,

View File

@ -732,7 +732,7 @@ export function createModel (builder: Builder): void {
}) })
builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.ClassFilters, { builder.mixin(tracker.class.Issue, core.class.Class, view.mixin.ClassFilters, {
filters: ['status', 'priority', 'assignee', 'project', 'dueDate', 'modifiedOn'] filters: ['status', 'priority', 'assignee', 'project', 'sprint', 'dueDate', 'modifiedOn']
}) })
builder.createDoc( builder.createDoc(

View File

@ -68,6 +68,7 @@
"KickEmployeeDescr": "Are you sure you want to kick the employee out of the workspace? This action cannot be undone", "KickEmployeeDescr": "Are you sure you want to kick the employee out of the workspace? This action cannot be undone",
"Email": "Email", "Email": "Email",
"CreateEmployee": "Create an employee", "CreateEmployee": "Create an employee",
"Inactive": "Inactive" "Inactive": "Inactive",
"Birthday": "Birthday"
} }
} }

View File

@ -68,6 +68,7 @@
"KickEmployeeDescr": "Вы действительно хотите выгнать сотрудника из рабочего пространства? Это действие нельзя отменить", "KickEmployeeDescr": "Вы действительно хотите выгнать сотрудника из рабочего пространства? Это действие нельзя отменить",
"Email": "Email", "Email": "Email",
"CreateEmployee": "Создать сотрудника", "CreateEmployee": "Создать сотрудника",
"Inactive": "Не активный" "Inactive": "Не активный",
"Birthday": "День рождения"
} }
} }

View File

@ -76,7 +76,9 @@ export interface Contact extends Doc {
/** /**
* @public * @public
*/ */
export interface Person extends Contact {} export interface Person extends Contact {
birthday?: Timestamp | null
}
/** /**
* @public * @public

View File

@ -168,6 +168,7 @@
<table class="antiTable"> <table class="antiTable">
<thead class="scroller-thead"> <thead class="scroller-thead">
<tr class="scroller-thead__tr"> <tr class="scroller-thead__tr">
<!-- <th>Field name</th> -->
<th> <th>
<div class="antiTable-cells"> <div class="antiTable-cells">
<Label label={settings.string.Attribute} /> <Label label={settings.string.Attribute} />
@ -197,6 +198,9 @@
} }
}} }}
> >
<!-- <td class='select-text'>
{attr.name}
</td> -->
<td> <td>
<div class="antiTable-cells__firstCell"> <div class="antiTable-cells__firstCell">
<Label label={attr.label} /> <Label label={attr.label} />
@ -207,7 +211,7 @@
{/if} {/if}
</div> </div>
</td> </td>
<td> <td class="select-text">
<Label label={attr.type.label} /> <Label label={attr.type.label} />
{#if attrType !== undefined} {#if attrType !== undefined}
: <Label label={attrType} /> : <Label label={attrType} />

View File

@ -36,7 +36,7 @@
const data: Data<AnyAttribute> = { const data: Data<AnyAttribute> = {
attributeOf: _class, attributeOf: _class,
name: name + generateId(), name: name.trim().replace('/', '').replace(' ', '') + '_' + generateId(),
label: getEmbeddedLabel(name), label: getEmbeddedLabel(name),
isCustom: true, isCustom: true,
type type

View File

@ -16,10 +16,6 @@
import { WithLookup } from '@anticrm/core' import { WithLookup } from '@anticrm/core'
import { Issue, IssueStatus } from '@anticrm/tracker' import { Issue, IssueStatus } from '@anticrm/tracker'
import { showPopup } from '@anticrm/ui' import { showPopup } from '@anticrm/ui'
import StatusFilterMenuSection from './StatusFilterMenuSection.svelte'
import PriorityFilterMenuSection from './PriorityFilterMenuSection.svelte'
import ProjectFilterMenuSection from './ProjectFilterMenuSection.svelte'
import FilterMenu from '../FilterMenu.svelte'
import { import {
defaultPriorities, defaultPriorities,
FilterAction, FilterAction,
@ -27,6 +23,11 @@
getIssueFilterAssetsByType, getIssueFilterAssetsByType,
IssueFilter IssueFilter
} from '../../utils' } from '../../utils'
import FilterMenu from '../FilterMenu.svelte'
import PriorityFilterMenuSection from './PriorityFilterMenuSection.svelte'
import ProjectFilterMenuSection from './ProjectFilterMenuSection.svelte'
import SprintFilterMenuSection from './SprintFilterMenuSection.svelte'
import StatusFilterMenuSection from './StatusFilterMenuSection.svelte'
export let targetHtml: HTMLElement export let targetHtml: HTMLElement
export let filters: IssueFilter[] = [] export let filters: IssueFilter[] = []
@ -42,6 +43,7 @@
$: groupedByStatus = getGroupedIssues('status', issues, defaultStatusIds) $: groupedByStatus = getGroupedIssues('status', issues, defaultStatusIds)
$: groupedByPriority = getGroupedIssues('priority', issues, defaultPriorities) $: groupedByPriority = getGroupedIssues('priority', issues, defaultPriorities)
$: groupedByProject = getGroupedIssues('project', issues) $: groupedByProject = getGroupedIssues('project', issues)
$: groupedBySprint = getGroupedIssues('sprint', issues)
const handleStatusFilterMenuSectionOpened = (event: MouseEvent | KeyboardEvent) => { const handleStatusFilterMenuSectionOpened = (event: MouseEvent | KeyboardEvent) => {
const statusGroups: { [key: string]: number } = {} const statusGroups: { [key: string]: number } = {}
@ -103,6 +105,25 @@
) )
} }
const handleSprintFilterMenuSectionOpened = (event: MouseEvent | KeyboardEvent) => {
const sprintGroups: { [key: string]: number } = {}
for (const [project, value] of Object.entries(groupedBySprint)) {
sprintGroups[project] = value?.length ?? 0
}
showPopup(
SprintFilterMenuSection,
{
groups: sprintGroups,
selectedElements: currentFilterQuery?.sprint?.[currentFilterMode] ?? [],
index,
onUpdate,
onBack
},
targetHtml
)
}
const actions: FilterAction[] = [ const actions: FilterAction[] = [
{ {
...getIssueFilterAssetsByType('status'), ...getIssueFilterAssetsByType('status'),
@ -115,6 +136,10 @@
{ {
...getIssueFilterAssetsByType('project'), ...getIssueFilterAssetsByType('project'),
onSelect: handleProjectFilterMenuSectionOpened onSelect: handleProjectFilterMenuSectionOpened
},
{
...getIssueFilterAssetsByType('sprint'),
onSelect: handleSprintFilterMenuSectionOpened
} }
] ]
</script> </script>

View File

@ -131,6 +131,7 @@
lookup: { lookup: {
status: [tracker.class.IssueStatus, { category: tracker.class.IssueStatusCategory }], status: [tracker.class.IssueStatus, { category: tracker.class.IssueStatusCategory }],
project: tracker.class.Project, project: tracker.class.Project,
sprint: tracker.class.Sprint,
assignee: contact.class.Employee assignee: contact.class.Employee
}, },
sort: issuesGroupBySorting[groupBy] sort: issuesGroupBySorting[groupBy]

View File

@ -0,0 +1,66 @@
<!--
// Copyright © 2022 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { translate } from '@anticrm/platform'
import { IconNavPrev } from '@anticrm/ui'
import FilterMenuSection from '../FilterMenuSection.svelte'
import tracker from '../../plugin'
import { FilterSectionElement } from '../../utils'
import { getClient } from '@anticrm/presentation'
export let selectedElements: any[] = []
export let groups: { [key: string]: number }
export let index: number = 0
export let onUpdate: (result: { [p: string]: any }, filterIndex?: number) => void
export let onBack: (() => void) | undefined = undefined
const getFilterElements = async (groups: { [key: string]: number }, selected: any[]) => {
const elements: FilterSectionElement[] = []
const client = getClient()
const sprints = await client.findAll(tracker.class.Sprint, {})
for (const [key, value] of Object.entries(groups)) {
const sprint = key === 'null' ? null : key
const label = sprint
? sprints.find(({ _id }) => _id === sprint)?.label
: await translate(tracker.string.NoSprint, {})
if (!label) {
continue
}
elements.splice(sprint ? 1 : 0, 0, {
icon: tracker.icon.Sprint,
title: label,
count: value,
isSelected: selected.includes(sprint),
onSelect: () => onUpdate({ sprint: sprint }, index)
})
}
return onBack
? [
{
icon: IconNavPrev,
title: await translate(tracker.string.Back, {}),
onSelect: onBack
},
...elements
]
: elements
}
</script>
{#await getFilterElements(groups, selectedElements) then actions}
<FilterMenuSection {actions} {onBack} on:close />
{/await}

View File

@ -39,6 +39,7 @@ export const issuesGroupByOptions: Record<IssuesGrouping, IntlString> = {
[IssuesGrouping.Assignee]: tracker.string.Assignee, [IssuesGrouping.Assignee]: tracker.string.Assignee,
[IssuesGrouping.Priority]: tracker.string.Priority, [IssuesGrouping.Priority]: tracker.string.Priority,
[IssuesGrouping.Project]: tracker.string.Project, [IssuesGrouping.Project]: tracker.string.Project,
[IssuesGrouping.Sprint]: tracker.string.Sprint,
[IssuesGrouping.NoGrouping]: tracker.string.NoGrouping [IssuesGrouping.NoGrouping]: tracker.string.NoGrouping
} }
@ -100,5 +101,6 @@ export const issuesGroupBySorting: Record<IssuesGrouping, SortingQuery<Issue>> =
[IssuesGrouping.Assignee]: { '$lookup.assignee.name': SortingOrder.Ascending }, [IssuesGrouping.Assignee]: { '$lookup.assignee.name': SortingOrder.Ascending },
[IssuesGrouping.Priority]: { priority: SortingOrder.Ascending }, [IssuesGrouping.Priority]: { priority: SortingOrder.Ascending },
[IssuesGrouping.Project]: { '$lookup.project.label': SortingOrder.Ascending }, [IssuesGrouping.Project]: { '$lookup.project.label': SortingOrder.Ascending },
[IssuesGrouping.Sprint]: { '$lookup.sprint.label': SortingOrder.Ascending },
[IssuesGrouping.NoGrouping]: {} [IssuesGrouping.NoGrouping]: {}
} }

View File

@ -51,7 +51,7 @@ export interface Selection {
currentSpecial?: string currentSpecial?: string
} }
export type IssuesGroupByKeys = keyof Pick<Issue, 'status' | 'priority' | 'assignee' | 'project'> export type IssuesGroupByKeys = keyof Pick<Issue, 'status' | 'priority' | 'assignee' | 'project' | 'sprint'>
export type IssuesOrderByKeys = keyof Pick<Issue, 'status' | 'priority' | 'modifiedOn' | 'dueDate' | 'rank'> export type IssuesOrderByKeys = keyof Pick<Issue, 'status' | 'priority' | 'modifiedOn' | 'dueDate' | 'rank'>
export const issuesGroupKeyMap: Record<IssuesGrouping, IssuesGroupByKeys | undefined> = { export const issuesGroupKeyMap: Record<IssuesGrouping, IssuesGroupByKeys | undefined> = {
@ -59,6 +59,7 @@ export const issuesGroupKeyMap: Record<IssuesGrouping, IssuesGroupByKeys | undef
[IssuesGrouping.Priority]: 'priority', [IssuesGrouping.Priority]: 'priority',
[IssuesGrouping.Assignee]: 'assignee', [IssuesGrouping.Assignee]: 'assignee',
[IssuesGrouping.Project]: 'project', [IssuesGrouping.Project]: 'project',
[IssuesGrouping.Sprint]: 'sprint',
[IssuesGrouping.NoGrouping]: undefined [IssuesGrouping.NoGrouping]: undefined
} }
@ -78,10 +79,11 @@ export const issuesSortOrderMap: Record<IssuesOrderByKeys, SortingOrder> = {
rank: SortingOrder.Ascending rank: SortingOrder.Ascending
} }
export const issuesGroupEditorMap: Record<'status' | 'priority' | 'project', AnyComponent | undefined> = { export const issuesGroupEditorMap: Record<'status' | 'priority' | 'project' | 'sprint', AnyComponent | undefined> = {
status: tracker.component.StatusEditor, status: tracker.component.StatusEditor,
priority: tracker.component.PriorityEditor, priority: tracker.component.PriorityEditor,
project: tracker.component.ProjectEditor project: tracker.component.ProjectEditor,
sprint: tracker.component.SprintEditor
} }
export const getIssuesModificationDatePeriodTime = (period: IssuesDateModificationPeriod | null): number => { export const getIssuesModificationDatePeriodTime = (period: IssuesDateModificationPeriod | null): number => {
@ -180,6 +182,12 @@ export const getIssueFilterAssetsByType = (type: string): { icon: Asset, label:
label: tracker.string.Project label: tracker.string.Project
} }
} }
case 'sprint': {
return {
icon: tracker.icon.Sprint,
label: tracker.string.Sprint
}
}
default: { default: {
return undefined return undefined
} }
@ -447,6 +455,21 @@ export async function getKanbanStatuses (
] ]
}, []) }, [])
} }
if (groupBy === IssuesGrouping.Sprint) {
const noSprint = await translate(tracker.string.NoSprint, {})
return issues.reduce<TypeState[]>((result, issue) => {
if (result.find(({ _id }) => _id === issue.sprint) !== undefined) return result
return [
...result,
{
_id: issue.sprint,
title: issue.$lookup?.sprint?.label ?? noSprint,
color: UNSET_COLOR,
icon: undefined
}
]
}, [])
}
return [] return []
} }
@ -481,6 +504,7 @@ export function getDefaultViewOptionsConfig (): ViewOptionModel[] {
{ id: 'assignee', label: tracker.string.Assignee }, { id: 'assignee', label: tracker.string.Assignee },
{ id: 'priority', label: tracker.string.Priority }, { id: 'priority', label: tracker.string.Priority },
{ id: 'project', label: tracker.string.Project }, { id: 'project', label: tracker.string.Project },
{ id: 'sprint', label: tracker.string.Sprint },
{ id: 'noGrouping', label: tracker.string.NoGrouping } { id: 'noGrouping', label: tracker.string.NoGrouping }
], ],
type: 'dropdown' type: 'dropdown'

View File

@ -73,6 +73,7 @@ export enum IssuesGrouping {
Assignee = 'assignee', Assignee = 'assignee',
Priority = 'priority', Priority = 'priority',
Project = 'project', Project = 'project',
Sprint = 'sprint',
NoGrouping = 'noGrouping' NoGrouping = 'noGrouping'
} }

View File

@ -16,6 +16,7 @@
<script lang="ts"> <script lang="ts">
import { Class, ClassifierKind, Doc, Mixin, Ref } from '@anticrm/core' import { Class, ClassifierKind, Doc, Mixin, Ref } from '@anticrm/core'
import { getClient } from '@anticrm/presentation' import { getClient } from '@anticrm/presentation'
import setting from '@anticrm/setting'
import { Label } from '@anticrm/ui' import { Label } from '@anticrm/ui'
import { getMixinStyle } from '../utils' import { getMixinStyle } from '../utils'
@ -41,7 +42,12 @@
mixins = hierarchy mixins = hierarchy
.getDescendants(parentClass) .getDescendants(parentClass)
.filter((m) => hierarchy.getClass(m).kind === ClassifierKind.MIXIN && hierarchy.hasMixin(value, m)) .filter(
(m) =>
hierarchy.getClass(m).kind === ClassifierKind.MIXIN &&
hierarchy.hasMixin(value, m) &&
!hierarchy.hasMixin(hierarchy.getClass(m), setting.mixin.UserMixin)
)
.map((m) => hierarchy.getClass(m) as Mixin<Doc>) .map((m) => hierarchy.getClass(m) as Mixin<Doc>)
} }
</script> </script>

View File

@ -111,45 +111,53 @@
const newValue = await result(filter, () => { const newValue = await result(filter, () => {
makeQuery(query, filters) makeQuery(query, filters)
}) })
if (newQuery[filter.key.key] === undefined) {
newQuery[filter.key.key] = newValue let filterKey = filter.key.key
const attr = client.getHierarchy().getAttribute(filter.key._class, filter.key.key)
if (client.getHierarchy().isMixin(attr.attributeOf)) {
filterKey = attr.attributeOf + '.' + filter.key.key
}
if (newQuery[filterKey] === undefined) {
newQuery[filterKey] = newValue
} else { } else {
let merged = false let merged = false
for (const key in newValue) { for (const key in newValue) {
if (newQuery[filter.key.key][key] === undefined) { if (newQuery[filterKey][key] === undefined) {
if (key === '$in' && typeof newQuery[filter.key.key] === 'string') { if (key === '$in' && typeof newQuery[filterKey] === 'string') {
newQuery[filter.key.key] = { $in: newValue[key].filter((p: any) => p === newQuery[filter.key.key]) } newQuery[filterKey] = { $in: newValue[key].filter((p: any) => p === newQuery[filterKey]) }
} else { } else {
newQuery[filter.key.key][key] = newValue[key] newQuery[filterKey][key] = newValue[key]
} }
merged = true merged = true
continue continue
} }
if (key === '$in') { if (key === '$in') {
newQuery[filter.key.key][key] = newQuery[filter.key.key][key].filter((p: any) => newValue[key].includes(p)) newQuery[filterKey][key] = newQuery[filterKey][key].filter((p: any) => newValue[key].includes(p))
merged = true merged = true
continue continue
} }
if (key === '$nin') { if (key === '$nin') {
newQuery[filter.key.key][key] = [...newQuery[filter.key.key][key], ...newValue[key]] newQuery[filterKey][key] = [...newQuery[filterKey][key], ...newValue[key]]
merged = true merged = true
continue continue
} }
if (key === '$lt') { if (key === '$lt') {
newQuery[filter.key.key][key] = newQuery[filterKey][key] =
newQuery[filter.key.key][key] < newValue[key] ? newQuery[filter.key.key][key] : newValue[key] newQuery[filterKey][key] < newValue[key] ? newQuery[filterKey][key] : newValue[key]
merged = true merged = true
continue continue
} }
if (key === '$gt') { if (key === '$gt') {
newQuery[filter.key.key][key] = newQuery[filterKey][key] =
newQuery[filter.key.key][key] > newValue[key] ? newQuery[filter.key.key][key] : newValue[key] newQuery[filterKey][key] > newValue[key] ? newQuery[filterKey][key] : newValue[key]
merged = true merged = true
continue continue
} }
} }
if (!merged) { if (!merged) {
Object.assign(newQuery[filter.key.key], newValue) Object.assign(newQuery[filterKey], newValue)
} }
} }
} }

View File

@ -33,7 +33,7 @@
function getTargetClass (): Ref<Class<Doc>> | undefined { function getTargetClass (): Ref<Class<Doc>> | undefined {
try { try {
return (hierarchy.getAttribute(_class, filter.key.key).type as RefTo<Doc>).to return (hierarchy.getAttribute(filter.key._class, filter.key.key).type as RefTo<Doc>).to
} catch (err: any) { } catch (err: any) {
console.error(err) console.error(err)
} }

View File

@ -35,7 +35,7 @@
const mixin = hierarchy.as(clazz, view.mixin.ClassFilters) const mixin = hierarchy.as(clazz, view.mixin.ClassFilters)
if (mixin.filters === undefined) return [] if (mixin.filters === undefined) return []
const filters = mixin.filters.map((p) => { const filters = mixin.filters.map((p) => {
return typeof p === 'string' ? buildFilterFromKey(p) : p return typeof p === 'string' ? buildFilterFromKey(_class, p) : p
}) })
const result: KeyFilter[] = [] const result: KeyFilter[] = []
for (const filter of filters) { for (const filter of filters) {
@ -44,12 +44,12 @@
return result return result
} }
function buildFilterFromKey (key: string): KeyFilter | undefined { function buildFilterFromKey (_class: Ref<Class<Doc>>, key: string): KeyFilter | undefined {
const attribute = hierarchy.getAttribute(_class, key) const attribute = hierarchy.getAttribute(_class, key)
return buildFilter(key, attribute) return buildFilter(_class, key, attribute)
} }
function buildFilter (key: string, attribute: AnyAttribute): KeyFilter | undefined { function buildFilter (_class: Ref<Class<Doc>>, key: string, attribute: AnyAttribute): KeyFilter | undefined {
const isCollection = hierarchy.isDerived(attribute.type._class, core.class.Collection) const isCollection = hierarchy.isDerived(attribute.type._class, core.class.Collection)
const targetClass = isCollection ? (attribute.type as Collection<AttachedDoc>).of : attribute.type._class const targetClass = isCollection ? (attribute.type as Collection<AttachedDoc>).of : attribute.type._class
const clazz = hierarchy.getClass(targetClass) const clazz = hierarchy.getClass(targetClass)
@ -73,17 +73,45 @@
return name return name
} }
function buildFilterForAttr (_class: Ref<Class<Doc>>, attribute: AnyAttribute, result: KeyFilter[]): void {
if (attribute.isCustom !== true) {
return
}
if (attribute.label === undefined || attribute.hidden) {
return
}
const value = getValue(attribute.name, attribute.type)
if (result.findIndex((p) => p.key === value) !== -1) {
return
}
const filter = buildFilter(_class, value, attribute)
if (filter !== undefined) {
result.push(filter)
}
}
function buildFilterFor (
_class: Ref<Class<Doc>>,
allAttributes: Map<string, AnyAttribute>,
result: KeyFilter[]
): void {
for (const [, attribute] of allAttributes) {
buildFilterForAttr(_class, attribute, result)
}
}
function getTypes (_class: Ref<Class<Doc>>): KeyFilter[] { function getTypes (_class: Ref<Class<Doc>>): KeyFilter[] {
const result = getFilters(_class) const result = getFilters(_class)
const allAttributes = hierarchy.getAllAttributes(_class) const allAttributes = hierarchy.getAllAttributes(_class)
for (const [, attribute] of allAttributes) { buildFilterFor(_class, allAttributes, result)
if (attribute.isCustom !== true) continue
if (attribute.label === undefined || attribute.hidden) continue const desc = hierarchy.getDescendants(_class)
const value = getValue(attribute.name, attribute.type) for (const d of desc) {
if (result.findIndex((p) => p.key === value) !== -1) continue const extra = hierarchy.getAllAttributes(d, _class)
const filter = buildFilter(value, attribute) for (const [k, v] of extra) {
if (filter !== undefined) { if (!allAttributes.has(k)) {
result.push(filter) allAttributes.set(k, v)
buildFilterForAttr(d, v, result)
}
} }
} }

View File

@ -32,7 +32,7 @@
const client = getClient() const client = getClient()
const key = { key: filter.key.key } const key = { key: filter.key.key }
const promise = getPresenter(client, _class, key, key) const promise = getPresenter(client, filter.key._class, key, key)
let values = new Map<any, number>() let values = new Map<any, number>()
let selectedValues: Set<any> = new Set<any>(filter.value.map((p) => p[0])) let selectedValues: Set<any> = new Set<any>(filter.value.map((p) => p[0]))
@ -53,8 +53,7 @@
} }
: {} : {}
let prefix = '' let prefix = ''
const attr = client.getHierarchy().getAttribute(filter.key._class, filter.key.key)
const attr = client.getHierarchy().getAttribute(_class, filter.key.key)
if (client.getHierarchy().isMixin(attr.attributeOf)) { if (client.getHierarchy().isMixin(attr.attributeOf)) {
prefix = attr.attributeOf + '.' prefix = attr.attributeOf + '.'
console.log('prefix', prefix) console.log('prefix', prefix)
@ -66,7 +65,11 @@
const res = await objectsPromise const res = await objectsPromise
for (const object of res) { for (const object of res) {
const realValue = getObjectValue(filter.key.key, object) let asDoc = object
if (client.getHierarchy().isMixin(filter.key._class)) {
asDoc = client.getHierarchy().as(object, filter.key._class)
}
const realValue = getObjectValue(filter.key.key, asDoc)
const value = getValue(realValue) const value = getValue(realValue)
values.set(value, (values.get(value) ?? 0) + 1) values.set(value, (values.get(value) ?? 0) + 1)
realValues.set(value, (realValues.get(value) ?? new Set()).add(realValue)) realValues.set(value, (realValues.get(value) ?? new Set()).add(realValue))