Compare commits

...

29 commits
master ... dev

Author SHA1 Message Date
Pavel Gnedov
8cae9076bd Добавлено описание архитектуры 2024-07-26 07:24:58 +07:00
Pavel Gnedov
4a420dcf71 Добавлена поддержка личных уведомлений для redmine5 2024-07-24 07:47:39 +07:00
Pavel Gnedov
f425bdf0ee Обращения к либе интеграции с telegram ботом обёрнуты в try catch
для предотвращения падений всего приложения
2024-06-10 10:38:23 +07:00
Pavel Gnedov
bc0bcd7554 Обновлён пакет node-telegram-bot-api 2024-05-22 22:29:32 +07:00
Pavel Gnedov
bca67b8699 Добавлен модуль working-time-calculator
Модуль для вспомогательных скриптов и развития линтера задач для проекта ECCM
2024-05-02 09:25:54 +07:00
Pavel Gnedov
b2ba939323 Добавлена api функция для отправки сообщений в telegram bot
/api/telegram-bot/send-message (POST)
2024-05-02 09:22:11 +07:00
Pavel Gnedov
5fb996a12e Добавлена проверка selector в IssuesService.mergedTreesAndFind 2024-04-07 21:47:24 +07:00
Pavel Gnedov
9bffaec6cf Добавлены дополнительные ендпоинты для загрузки данных из кеша
* добавлен ендпоинт find-from-root - для рекурсивной загрузки подзадач от корневой задачи
* добавлен ендпоинт find-from-merged-trees-and-query - для гибридной загрузки задач от корневых + с помощью find запроса для couchdb
2024-04-05 18:56:40 +07:00
Pavel Gnedov
6df04c8e6d Исправлены ошибки с выводом в лог не проверенных данных 2024-02-29 07:12:16 +07:00
Pavel Gnedov
006f9a6d28 Исправлена ошибка из-за попытки загружать issueId <= 0 2024-02-28 10:31:48 +07:00
Pavel Gnedov
18873337b1 Добавлены дополнительные стратегии для синхронизации с redmine
* через запрос данных из csv
* через прогрузку суммирующих задач
2024-02-22 13:07:54 +07:00
Pavel Gnedov
9d476a43fa Добавлен вспомогательный скрипт для принудительной синхронизации суммирующих задач 2024-02-20 16:56:41 +07:00
Pavel Gnedov
afad88c057 Добавлен cli скрипт для синхронизации закешированных задач с задачами в redmine 2024-02-08 00:44:20 +07:00
Pavel Gnedov
f5b547e8ce Добавлена инструкция по установке 2024-02-05 08:17:45 +07:00
Pavel Gnedov
1983c88f8f Merge branch 'feature/calendar-widget' into dev
Создан виджет с календарём для дашбордов
2024-02-02 09:42:57 +07:00
Pavel Gnedov
3fa965ff69 Добавлен документ описывающий пошагово как создавать кастомный виджет 2024-02-02 09:39:55 +07:00
Pavel Gnedov
3c2fe9af95 Добавлены комментарии в код виджета календаря на frontend-е 2024-02-02 09:39:14 +07:00
Pavel Gnedov
d9c3345594 Убрана лишняя переменная 2024-02-02 08:56:24 +07:00
Pavel Gnedov
fd121d5db1 Исправлена обработка ошибки для пустых дашбордов или полностью свёрнутых изначально 2024-02-02 07:59:16 +07:00
Pavel Gnedov
50f7aeaf2f Добавлен загрузчик данных для календаря в дашбордах 2024-02-02 07:54:59 +07:00
Pavel Gnedov
d7b37470ef Для дашборда создан виджет календаря 2024-02-02 07:51:50 +07:00
Pavel Gnedov
ddb5df9254 Укорощение длинных сообщений в telegram боте 2024-01-19 19:50:25 +07:00
Pavel Gnedov
f068c9d6ab Добавлен пропущенный import 2023-11-23 07:51:00 +07:00
Pavel Gnedov
5cb3df078f Настройка monaco editor на использование отступов в 2 пробела в редакторе дашборда 2023-11-16 19:49:37 +07:00
Pavel Gnedov
5ec0c1d99e Настройка monaco editor на использование отступов в 2 пробела в редакторе дашборда 2023-11-15 08:23:12 +07:00
Pavel Gnedov
387a1b28c3 Исправлены ошибки линтера 2023-11-10 07:45:41 +07:00
Pavel Gnedov
d43cc54f56 Исправлены ошибки линтера 2023-11-10 07:45:41 +07:00
Pavel Gnedov
ea8115cbee Исправлены ошибки линтера 2023-11-10 07:45:41 +07:00
Pavel Gnedov
f2e0bd7f47 Merge branch 'master' into dev 2023-11-10 07:43:52 +07:00
57 changed files with 6698 additions and 822 deletions

View file

@ -0,0 +1,22 @@
{
// TODO:
"useForProjects": [
"Test project name 1",
"Test project name 2"
],
// TODO:
"customFields": [
{
"dateFormat": "yyyy-MM-dd HH:mm",
"customFieldName": "Название кастомного поля 1",
"alias": "Название кастомного поля 1"
}
],
"descriptionCalendarParams": {
"title": "Календарь:",
"lineRegexp": "(?<=\\*\\ ).+"
},
"calendarEventsKey": "calendar"
}

View file

@ -0,0 +1,14 @@
{
"New": "dev",
"In Progress": "dev",
"Re-opened": "dev",
"Code Review": "cr",
"Resolved": "qa",
"Testing": "qa",
"Wait Release": "dev",
"Pending": "dev",
"Feedback": "qa",
"Closed": "dev",
"Rejected": "dev",
"*": "dev"
}

View file

@ -0,0 +1,24 @@
// TODO:
{
"currentVersions": ["1", "0.1"],
"projectName": "Test project name 1",
"currentIssuesStatuses": ["In Progress", "Re-opened", "Code Review", "Resolved", "Testing", "Feedback"],
"groups": [
{
"name": "Dev backend",
"people": [
"User Developer"
]
},
{
"name": "QA",
"people": [
"Redmine Admin"
]
}
],
"dailyTime": {
"hour": 10,
"minute": 30
}
}

View file

@ -0,0 +1,50 @@
{
// "mailListener": {
// "issueNumberParser": "\\b(?<=#)\\d+\\b",
// "imapSimpleConfig": {
// "imap": {
// "user": "",
// "password": "",
// "host": "",
// "port": 143,
// // tls: true,
// "autotls": "always",
// "authTimeout": 5000
// }
// },
// "updateInterval": 180000, // 3 min
// "boxName": "INBOX"
// },
"rssListener": {
"subscriptions": [
{
"url": "https://REDMINE_HOST/projects/proj/activity.atom?key=....", // TODO
"issueNumberParser": "\\b(?<=#)\\d+\\b"
},
{
"url": "https://REDMINE_HOST/activity.atom?key=....", // TODO
"issueNumberParser": "\\b(?<=#)\\d+\\b"
}
],
"updateInterval": 10000 // 10 sec
},
"issueChangesQueue": {
"updateInterval": 5000, // 5 sec
"itemsLimit": 3
},
"redmineUrlPrefix": "https://REDMINE_API_TOKEN@REDMINE_HOST", // TODO
"redmineUrlPublic": "https://REDMINE_HOST", // TODO
"webhooks": [],
"couchDb": {
"url": "http://admin:password@localhost:5984", // <- TODO: Указать host, port, username и password для доступа к couchdb
"dbs": {
"users": "redmine_users",
"issues": "redmine_issues",
"dashboards": "dashboards"
}
}
}

View file

@ -0,0 +1,18 @@
{
"couchDb": {
"dbs": {
"changes": "redmine_changes",
"userMetaInfo": "user_meta_info",
"eccmDailyReports": "eccm_daily_reports",
"eccmDailyReportsUserComments": "eccm_daily_reports_user_comments"
}
},
"telegramBotToken": "....", // <- TODO: Указать token от бота telegram
"personalMessageTemplate": "{{{issue_url}}} {{{sender_name}}}:\n\n{{{message}}}",
"periodValidityNotification": 43200000, // 12h
"tagManager": {
"updateInterval": 15000,
"updateItemsLimit": 3,
"tagsCustomFieldName": "Tags"
}
}

View file

@ -0,0 +1,225 @@
// TODO: Указать правила для формирования сообщений с уведомлениями об изменениях статусов
[
{
"default": false,
"from": "New",
"to": "In Progress",
"messages": [
{
"recipient": "dev",
"changes_message": "{{dev.name}} взял в работу задачу #{{issue_id}}"
}
]
},
{
"default": false,
"from": "In Progress",
"to": "Code Review",
"messages": [
{
"recipient": "dev",
"changes_message": "{{dev.name}} завершил разработку по задаче #{{issue_id}} и передал на ревью {{cr.name}}"
},
{
"recipient": "cr",
"changes_message": "{{cr.name}} получил задачу #{{issue_id}} на ревью от {{dev.name}}",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{issue_subject}}:\n{{dev.name}} завершил разработку по задаче и передал вам на ревью\n\n{{journal_note}}"
}
]
},
{
"default": false,
"from": "Code Review",
"to": "Re-opened",
"messages": [
{
"recipient": "cr",
"changes_message": "{{cr.name}} вернул задачу #{{issue_id}} на доработку {{dev.name}}"
},
{
"recipient": "dev",
"changes_message": "{{dev.name}} получил задачу #{{issue_id}} на доработку после завершения ревью {{cr.name}}",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{cr.name}} вернул вам с ревью на доработку задачу\n\n{{journal_note}}"
}
]
},
{
"default": false,
"from": "Re-opened",
"to": "In Progress",
"messages": [
{
"recipient": "dev",
"changes_message": "{{dev.name}} продолжил работу над задачей #{{issue_id}}"
}
]
},
{
"default": false,
"from": "Code Review",
"to": "Resolved",
"messages": [
{
"recipient": "cr",
"changes_message": "{{cr.name}} завершил ревью задачи {{issue_id}} и передал её на тест {{qa.name}}"
},
{
"recipient": "qa",
"changes_message": "{{qa.name}} получил на тест задачу #{{issue_id}} после разработки {{dev.name}} и ревью {{cr.name}}",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{dev.name}} завершил разработку по задаче, а {{cr.name}} выполнил ревью и передали теперь вам для проверки\n\n{{journal_note}}"
},
{
"recipient": "dev",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{cr.name}} выполнил ревью вашей задачи и передал далее на проверку {{qa.name}}\n\n{{journal_note}}"
}
]
},
{
"default": false,
"from": "Re-opened",
"to": "Code Review",
"messages": [
{
"recipient": "dev",
"changes_message": "{{dev.name}} выполнил доработки и перевёл задачу #{{issue_id}} на ревью {{cr.name}}"
},
{
"recipient": "cr",
"changes_message": "{{cr.name}} получил задачу #{{issue_id}} на повторное ревью от {{dev.name}}",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{dev.name}} выполнил по задаче доработки и теперь вы можете сделать ревью\n\n{{journal_note}}"
}
]
},
{
"default": false,
"from": "Resolved",
"to": "Closed",
"messages": [
{
"recipient": "qa",
"changes_message": "{{qa.name}} протестировал задачу #{{issue_id}} и закрыл"
},
{
"recipient": "dev",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{qa.name}} проверил и закрыл вашу задачу"
}
]
},
{
"default": false,
"from": "Testing",
"to": "Re-opened",
"messages": [
{
"recipient": "qa",
"changes_message": "{{qa.name}} вернул задачу #{{issue_id}} на доработку {{dev.name}}"
},
{
"recipient": "dev",
"changes_message": "{{dev.name}} получил от {{qa.name}} задачу #{{issue_id}} на доработку",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{qa.name}} вернул вам с тестирования задачу на доработку\n\n{{journal_note}}"
}
]
},
{
"default": false,
"from": "Resolved",
"to": "Re-opened",
"messages": [
{
"recipient": "qa",
"changes_message": "{{qa.name}} вернул задачу #{{issue_id}} на доработку {{dev.name}}"
},
{
"recipient": "dev",
"changes_message": "{{dev.name}} получил от {{qa.name}} задачу #{{issue_id}} на доработку",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{qa.name}} вернул вам с тестирования задачу на доработку\n\n{{journal_note}}"
}
]
},
{
"default": false,
"from": "Resolved",
"to": "Testing",
"messages": [
{
"recipient": "qa",
"changes_message": "{{qa.name}} начал проверять задачу #{{issue_id}}"
}
]
},
{
"default": false,
"from": "Testing",
"to": "Closed",
"messages": [
{
"recipient": "qa",
"changes_message": "{{qa.name}} протестировал задачу #{{issue_id}} и закрыл"
},
{
"recipient": "dev",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{qa.name}} проверил и закрыл вашу задачу"
}
]
},
{
"default": false,
"to": "Rejected",
"messages": [
{
"recipient": "initiator",
"changes_message": "{{initiator.name}} изменил статус задачи {{issue_id}} с {{old_status.name}} на {{new_status.name}} (dev - {{dev.name}}, cr - {{cr.name}}, qa - {{qa.name}})"
}
]
},
{
"default": true,
"messages": [
{
"recipient": "initiator",
"changes_message": "{{initiator.name}} изменил статус задачи #{{issue_id}} с {{old_status.name}} на {{new_status.name}} (dev - {{dev.name}}, cr - {{cr.name}}, qa - {{qa.name}})"
},
{
"recipient": "dev",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{initiator.name}} изменил статус задачи с {{old_status.name}} на {{new_status.name}}"
},
{
"recipient": "current_user",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{initiator.name}} изменил статус задачи с {{old_status.name}} на {{new_status.name}}"
}
]
},
{
"default": false,
"new_issue": true,
"from": "New",
"to": "New",
"messages": [
{
"recipient": "author",
"changes_message": "{{author.name}} создал новую задачу #{{issue_id}} {{#if current_user}}для {{current_user.name}}{{/if}}"
},
{
"recipient": "current_user",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{author.name}} создал новую задачу и назначил её на вас"
}
]
},
{
"default": false,
"from": "Re-opened",
"to": "Resolved",
"messages": [
{
"recipient": "dev",
"changes_message": "{{dev.name}} перевёл задачу #{{issue_id}} на повторную проверку {{qa.name}}"
},
{
"recipient": "qa",
"changes_message": "{{qa.name}} получил задачу #{{issue_id}} на повторную проверку от {{dev.name}}",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{dev.name}} выполнил по задаче доработки и теперь вы можете проверить повторно\n\n{{journal_note}}"
}
]
}
]

View file

@ -0,0 +1,60 @@
// TODO: Указать какие статусы могут быть в вашем Redmine
[
{
"id": 1,
"name": "New"
},
{
"id": 4,
"name": "In Progress"
},
{
"id": 5,
"name": "Re-opened"
},
{
"id": 6,
"name": "Code Review"
},
{
"id": 7,
"name": "Resolved"
},
{
"id": 8,
"name": "Testing"
},
{
"id": 9,
"name": "Wait Release"
},
{
"id": 10,
"name": "Pending"
},
{
"id": 11,
"name": "Feedback"
}
// {
// "id": 5,
// "name": "Closed",
// "is_closed": true
// },
// {
// "id": 6,
// "name": "Rejected",
// "is_closed": true
// },
// {
// "id": 7,
// "name": "Confirming"
// },
// {
// "id": 12,
// "name": "Frozen",
// "is_closed": true
// },
// ...
]

View file

@ -0,0 +1,4 @@
// deprecated
{
"path": "/path/to/eltex-redmine-issue-event-emitter/configs/kanban-boards"
}

View file

@ -24,10 +24,32 @@
],
"updateInterval": 600000 // 10 min
},
"csvListener": {
"tasks": [
{
"schedule": "", // cron schedule syntax
"updatedAtFieldName": "",
"dateTimeFormat": "",
"csvLinks": [
"",
""
]
}
]
},
"rootIssueListener": {
"tasks": [
{
"schedule": "", // cron schedule syntax
"rootIssues": [] // number[]
}
]
},
"issueChangesQueue": {
"updateInterval": 5000, // 5 sec
"itemsLimit": 3
},
"redmineToken": "",
"redmineUrlPrefix": "",
"redmineUrlPublic": "",
"webhooks": [

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

View file

@ -0,0 +1,252 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="380.13333mm"
height="110.25833mm"
viewBox="0 0 380.13334 110.25833"
version="1.1"
id="svg8"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
sodipodi:docname="Преобразования задач.svg"
inkscape:export-filename="/home/pavel/obsidian/Личные/Проекты/Монолитный Redmine Event Emitter/Документация/_resources/Преобразования задач.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs2">
<marker
style="overflow:visible;"
id="marker1241"
refX="0.0"
refY="0.0"
orient="auto"
inkscape:stockid="Arrow2Lend"
inkscape:isstock="true">
<path
transform="scale(1.1) rotate(180) translate(1,0)"
d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z "
style="fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round;stroke:#000000;stroke-opacity:1;fill:#000000;fill-opacity:1"
id="path1239" />
</marker>
<marker
style="overflow:visible;"
id="marker1213"
refX="0.0"
refY="0.0"
orient="auto"
inkscape:stockid="Arrow2Lend"
inkscape:isstock="true">
<path
transform="scale(1.1) rotate(180) translate(1,0)"
d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z "
style="fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round;stroke:#000000;stroke-opacity:1;fill:#000000;fill-opacity:1"
id="path1211" />
</marker>
<marker
style="overflow:visible;"
id="Arrow2Lend"
refX="0.0"
refY="0.0"
orient="auto"
inkscape:stockid="Arrow2Lend"
inkscape:isstock="true"
inkscape:collect="always">
<path
transform="scale(1.1) rotate(180) translate(1,0)"
d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z "
style="fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round;stroke:#000000;stroke-opacity:1;fill:#000000;fill-opacity:1"
id="path918" />
</marker>
<marker
style="overflow:visible;"
id="Arrow2Mend"
refX="0.0"
refY="0.0"
orient="auto"
inkscape:stockid="Arrow2Mend"
inkscape:isstock="true">
<path
transform="scale(0.6) rotate(180) translate(0,0)"
d="M 8.7185878,4.0337352 L -2.2072895,0.016013256 L 8.7185884,-4.0017078 C 6.9730900,-1.6296469 6.9831476,1.6157441 8.7185878,4.0337352 z "
style="fill-rule:evenodd;stroke-width:0.625;stroke-linejoin:round;stroke:#000000;stroke-opacity:1;fill:#000000;fill-opacity:1"
id="path924" />
</marker>
<rect
x="124.35417"
y="15.875"
width="105.83333"
height="31.75"
id="rect879" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="1"
inkscape:pageshadow="2"
inkscape:zoom="0.98994949"
inkscape:cx="755.38262"
inkscape:cy="192.02171"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="true"
inkscape:window-width="1856"
inkscape:window-height="1051"
inkscape:window-x="64"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:snap-text-baseline="true"
fit-margin-top="10"
lock-margins="true"
fit-margin-left="10"
fit-margin-right="10"
fit-margin-bottom="10">
<inkscape:grid
type="xygrid"
id="grid36"
originx="2.2125001"
originy="52.483332" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(2.2125,52.483332)">
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:85.6522;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="22.545856"
y="31.792259"
id="text30"
transform="translate(-6.6699219,-2.6880925)"><tspan
x="22.545856"
y="31.792259"><tspan
style="stroke-width:0.3">Чтение данных из </tspan></tspan><tspan
x="22.545856"
y="39.729758"><tspan
style="stroke-width:0.3">redmine </tspan><tspan
style="stroke-width:0.3">через </tspan><tspan
style="stroke-width:0.3">http api</tspan></tspan></text>
<rect
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
id="rect38"
width="100.54166"
height="31.75"
x="7.9375"
y="15.875" />
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="132.29167"
y="37.041668"
id="text865"><tspan
sodipodi:role="line"
id="tspan863"
x="132.29167"
y="37.041668"
style="stroke-width:0.3" /></text>
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6673;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="18.520834"
y="-7.9375"
id="text869"
transform="translate(-2.6464845,-23.812501)"><tspan
x="18.520834"
y="-7.9375"><tspan
style="stroke-width:0.3">Указание коллекции </tspan></tspan><tspan
x="18.520834"
y="2.970919e-07"><tspan
style="stroke-width:0.3">функций-</tspan></tspan><tspan
x="18.520834"
y="7.9375003"><tspan
style="stroke-width:0.3">преобразователей при </tspan></tspan><tspan
x="18.520834"
y="15.875"><tspan
style="stroke-width:0.3">инициализии приложения</tspan></tspan></text>
<rect
style="fill:none;stroke:#000000;stroke-width:0.30000001;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:1.20000006,1.20000006;stroke-dashoffset:0"
id="rect871"
width="100.54166"
height="42.333332"
x="7.9375"
y="-42.333332" />
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:119.062;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="132.29167"
y="29.104166"
id="text875"><tspan
x="132.29167"
y="29.104166"><tspan
style="stroke-width:0.3">Выполнение всех функций-</tspan></tspan><tspan
x="132.29167"
y="37.041666"><tspan
style="stroke-width:0.3">преобразователей</tspan></tspan></text>
<text
xml:space="preserve"
id="text877"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;shape-inside:url(#rect879);fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000;" />
<rect
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
id="rect883"
width="108.47916"
height="31.75"
x="124.35416"
y="15.875" />
<path
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.30000001;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:1.20000006,1.20000006;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1;marker-end:url(#Arrow2Lend)"
d="m 108.47917,-23.8125 c 39.6875,0 63.5,10.583333 63.49999,39.6875"
id="path885"
sodipodi:nodetypes="cc" />
<path
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.3;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1;marker-end:url(#marker1241)"
d="m 108.47917,31.75 h 15.875"
id="path887" />
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:113.771;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="261.9375"
y="29.10417"
id="text891"
transform="translate(-5.2916514)"><tspan
x="261.9375"
y="29.10417"><tspan
style="stroke-width:0.3">Дальнейшая работа с данными </tspan></tspan><tspan
x="261.9375"
y="37.04167"><tspan
style="stroke-width:0.3">(сохранение в </tspan><tspan
style="stroke-width:0.3">CouchDB</tspan><tspan
style="stroke-width:0.3">)</tspan></tspan></text>
<rect
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0"
id="rect893"
width="119.0625"
height="31.75"
x="248.70833"
y="15.875" />
<path
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.3;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1;marker-end:url(#marker1213)"
d="m 232.83333,31.749999 h 15.875"
id="path895" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

View file

@ -0,0 +1,462 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="265.65228mm"
height="517.26074mm"
viewBox="0 0 265.65229 517.26075"
version="1.1"
id="svg8"
inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
sodipodi:docname="Сохранение в CouchDB.svg"
inkscape:export-filename="/home/pavel/obsidian/Личные/Проекты/Монолитный Redmine Event Emitter/Документация/_resources/Сохранение в CouchDB.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="1"
inkscape:pageshadow="2"
inkscape:zoom="0.49497475"
inkscape:cx="530.06227"
inkscape:cy="959.12713"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
inkscape:document-rotation="0"
showgrid="false"
inkscape:window-width="928"
inkscape:window-height="1051"
inkscape:window-x="992"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:snap-text-baseline="true"
showguides="false"
fit-margin-top="10"
lock-margins="true"
fit-margin-left="10"
fit-margin-right="10"
fit-margin-bottom="10">
<inkscape:grid
type="xygrid"
id="grid28"
originx="49.837479"
originy="-14.118424" />
<sodipodi:guide
position="60.420812,512.85835"
orientation="300,0"
id="guide873" />
<sodipodi:guide
position="160.96248,436.12918"
orientation="110.00002,0"
id="guide885" />
<sodipodi:guide
position="92.170813,433.48335"
orientation="0,105"
id="guide889" />
<sodipodi:guide
position="71.004145,420.25418"
orientation="165,0"
id="guide927" />
<sodipodi:guide
position="171.54582,420.25418"
orientation="60,0"
id="guide931" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Слой 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(49.837477,-14.118424)">
<text
xml:space="preserve"
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="-39.6875"
y="44.979164"
id="text857"><tspan
sodipodi:role="line"
x="-39.6875"
y="44.979164"
style="font-size:4.23333px;stroke-width:0.3"
id="tspan859">{</tspan><tspan
sodipodi:role="line"
x="-39.6875"
y="50.270828"
style="font-size:4.23333px;stroke-width:0.3"
id="tspan861"> &quot;_id&quot;: &quot;8ccc&quot;,</tspan><tspan
sodipodi:role="line"
x="-39.6875"
y="55.562489"
style="font-size:4.23333px;stroke-width:0.3"
id="tspan863"> &quot;_rev&quot;: &quot;0-abcd&quot;,</tspan><tspan
sodipodi:role="line"
x="-39.6875"
y="60.854149"
style="font-size:4.23333px;stroke-width:0.3"
id="tspan865"> &quot;field_a&quot;: &quot;value_a&quot;</tspan><tspan
sodipodi:role="line"
x="-39.6875"
y="66.145813"
style="font-size:4.23333px;stroke-width:0.3"
id="tspan867">}</tspan></text>
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="10.583333"
y="97.895836"
id="text877"><tspan
sodipodi:role="line"
id="tspan875"
x="10.583333"
y="97.895836"
style="stroke-width:0.3">Поток 1</tspan></text>
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="10.583333"
y="119.0625"
id="text881"
transform="translate(10.582682,5.2916667)"><tspan
x="10.583333"
y="119.0625"><tspan
style="stroke-width:0.3">Чтение документа из </tspan></tspan><tspan
x="10.583333"
y="127"><tspan
style="stroke-width:0.3">коллекции</tspan></tspan></text>
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="111.125"
y="97.895836"
id="text893"><tspan
sodipodi:role="line"
id="tspan891"
x="111.125"
y="97.895836"
style="stroke-width:0.3">Поток 2</tspan></text>
<text
xml:space="preserve"
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#008000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="21.166666"
y="153.45833"
id="text901"
transform="translate(6.5094167e-4,3.9687551)"><tspan
x="21.166666"
y="153.45833"><tspan
style="font-size:4.23333px;fill:#008000;stroke-width:0.3">{
</tspan></tspan><tspan
x="21.166666"
y="158.74999"><tspan
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> id: 8ccc,
</tspan></tspan><tspan
x="21.166666"
y="164.04165"><tspan
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> rev: 0-abcd,
</tspan></tspan><tspan
x="21.166666"
y="169.3333"><tspan
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> field_a: value_a
</tspan></tspan><tspan
x="21.166666"
y="174.62496"><tspan
style="font-size:4.23333px;fill:#008000;stroke-width:0.3">}</tspan></tspan></text>
<rect
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none"
id="rect903"
width="5.2916665"
height="95.250008"
x="10.583333"
y="111.125" />
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="10.583333"
y="119.0625"
id="text881-6"
transform="translate(111.12435,44.979173)"><tspan
x="10.583333"
y="119.0625"><tspan
style="stroke-width:0.3">Чтение документа из </tspan></tspan><tspan
x="10.583333"
y="127"><tspan
style="stroke-width:0.3">коллекции</tspan></tspan></text>
<rect
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none"
id="rect903-5"
width="5.2916665"
height="140.22917"
x="111.125"
y="150.8125" />
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="10.583333"
y="119.0625"
id="text881-3"
transform="translate(10.582683,124.35415)"><tspan
x="10.583333"
y="119.0625"><tspan
style="stroke-width:0.3">Запись новой версии </tspan></tspan><tspan
x="10.583333"
y="127"><tspan
style="stroke-width:0.3">документа в </tspan><tspan
style="stroke-width:0.3">коллекцию</tspan></tspan></text>
<text
xml:space="preserve"
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="21.166666"
y="153.45833"
id="text901-5"
transform="translate(6.4991001e-4,113.77082)"><tspan
x="21.166666"
y="153.45833"><tspan
style="font-size:4.23333px">{
</tspan></tspan><tspan
x="21.166666"
y="158.74999"><tspan
style="font-size:4.23333px"> id: 8ccc,
</tspan></tspan><tspan
x="21.166666"
y="164.04165"><tspan
style="font-size:4.23333px"> rev: 0-abcd,
</tspan></tspan><tspan
x="21.166666"
y="169.3333"><tspan
style="font-size:4.23333px"> </tspan><tspan
style="font-size:4.23333px;fill:#0000ff">field_a: success_value</tspan><tspan
style="font-size:4.23333px">
</tspan></tspan><tspan
x="21.166666"
y="174.62496"><tspan
style="font-size:4.23333px">}</tspan></tspan></text>
<rect
style="fill:none;stroke:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none"
id="rect903-6"
width="5.2916665"
height="137.58333"
x="10.583333"
y="230.1875" />
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#008000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="21.166666"
y="333.375"
id="text1140"
transform="translate(6.4991001e-4,-26.458354)"><tspan
x="21.166666"
y="333.375"><tspan
style="fill:#008000;stroke-width:0.3">Успех. Документу </tspan></tspan><tspan
x="21.166666"
y="341.31251"><tspan
style="fill:#008000;stroke-width:0.3">внутри </tspan><tspan
style="fill:#008000;stroke-width:0.3">CouchDB </tspan><tspan
style="fill:#008000;stroke-width:0.3">будет </tspan></tspan><tspan
x="21.166666"
y="349.25001"><tspan
style="fill:#008000;stroke-width:0.3">проставлена новая </tspan></tspan><tspan
x="21.166666"
y="357.18751"><tspan
style="fill:#008000;stroke-width:0.3">ревизия - 1-</tspan><tspan
style="fill:#008000;stroke-width:0.3">bcde</tspan></tspan></text>
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="-39.6875"
y="29.104166"
id="text1260"><tspan
sodipodi:role="line"
id="tspan1258"
x="-39.6875"
y="29.104166"
style="stroke-width:0.3">Исходный документ:</tspan></text>
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#008000;stroke:none;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="21.166666"
y="148.16667"
id="text1268"><tspan
sodipodi:role="line"
id="tspan1266"
x="21.166666"
y="148.16667"
style="fill:#008000;stroke:none;stroke-width:0.3">Получили:</tspan></text>
<text
xml:space="preserve"
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#008000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="21.166666"
y="153.45833"
id="text901-5-0"
transform="translate(6.4964419e-4,187.85415)"><tspan
x="21.166666"
y="153.45833"><tspan
style="font-size:4.23333px;fill:#008000">{
</tspan></tspan><tspan
x="21.166666"
y="158.74999"><tspan
style="font-size:4.23333px;fill:#008000"> id: 8ccc,
</tspan></tspan><tspan
x="21.166666"
y="164.04165"><tspan
style="font-size:4.23333px;fill:#008000"> rev: 1-bcde,
</tspan></tspan><tspan
x="21.166666"
y="169.3333"><tspan
style="font-size:4.23333px;fill:#008000"> </tspan><tspan
style="font-size:4.23333px;fill:#008000">field_a: success_value</tspan><tspan
style="font-size:4.23333px;fill:#008000">
</tspan></tspan><tspan
x="21.166666"
y="174.62496"><tspan
style="font-size:4.23333px;fill:#008000">}</tspan></tspan></text>
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="10.583333"
y="119.0625"
id="text881-3-9"
transform="translate(111.12433,285.74998)"><tspan
x="10.583333"
y="119.0625"><tspan
style="stroke-width:0.3">Запись новой версии </tspan></tspan><tspan
x="10.583333"
y="127"><tspan
style="stroke-width:0.3">документа в </tspan><tspan
style="stroke-width:0.3">коллекцию</tspan></tspan></text>
<text
xml:space="preserve"
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="21.166666"
y="153.45833"
id="text901-5-3"
transform="translate(100.5423,275.16665)"><tspan
x="21.166666"
y="153.45833"><tspan
style="font-size:4.23333px">{
</tspan></tspan><tspan
x="21.166666"
y="158.74999"><tspan
style="font-size:4.23333px"> id: 8ccc,
</tspan></tspan><tspan
x="21.166666"
y="164.04165"><tspan
style="font-size:4.23333px"> rev: 0-abcd,
</tspan></tspan><tspan
x="21.166666"
y="169.3333"><tspan
style="font-size:4.23333px"> </tspan><tspan
style="font-size:4.23333px;fill:#0000ff">field_a: fail_value</tspan><tspan
style="font-size:4.23333px">
</tspan></tspan><tspan
x="21.166666"
y="174.62496"><tspan
style="font-size:4.23333px">}</tspan></tspan></text>
<rect
style="fill:none;stroke:#000000;stroke-width:0.300001;stroke-linecap:round;stroke-linejoin:bevel;stroke-miterlimit:4;stroke-dasharray:none"
id="rect903-6-6"
width="5.2916665"
height="124.35415"
x="111.125"
y="391.58337" />
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#ff0000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="21.166666"
y="333.375"
id="text1140-0"
transform="translate(100.5423,134.93748)"><tspan
x="21.166666"
y="333.375"><tspan
style="fill:#ff0000">Fail. </tspan><tspan
style="fill:#ff0000">Ревизия не </tspan></tspan><tspan
x="21.166666"
y="341.31251"><tspan
style="fill:#ff0000">совпадает с предыдущим </tspan></tspan><tspan
x="21.166666"
y="349.25001"><tspan
style="fill:#ff0000">значением:
</tspan></tspan><tspan
x="21.166666"
y="357.18751"><tspan
style="fill:#ff0000">&quot;0-</tspan><tspan
style="fill:#ff0000">abcd&quot; != &quot;1-bcde&quot;.
</tspan></tspan><tspan
x="21.166666"
y="365.12501"><tspan
style="fill:#ff0000">Документ не будет </tspan></tspan><tspan
x="21.166666"
y="373.06251"><tspan
style="fill:#ff0000">обновлён.</tspan></tspan></text>
<text
xml:space="preserve"
style="font-size:4.23333px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;white-space:pre;inline-size:84.6667;fill:#008000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="21.166666"
y="153.45833"
id="text901-2"
transform="translate(100.54233,43.656245)"><tspan
x="21.166666"
y="153.45833"><tspan
style="font-size:4.23333px;fill:#008000;stroke-width:0.3">{
</tspan></tspan><tspan
x="21.166666"
y="158.74999"><tspan
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> id: 8ccc,
</tspan></tspan><tspan
x="21.166666"
y="164.04165"><tspan
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> rev: 0-abcd,
</tspan></tspan><tspan
x="21.166666"
y="169.3333"><tspan
style="font-size:4.23333px;fill:#008000;stroke-width:0.3"> field_a: value_a
</tspan></tspan><tspan
x="21.166666"
y="174.62496"><tspan
style="font-size:4.23333px;fill:#008000;stroke-width:0.3">}</tspan></tspan></text>
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#008000;stroke:none;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="121.70834"
y="187.85417"
id="text1268-6"><tspan
sodipodi:role="line"
id="tspan1266-1"
x="121.70834"
y="187.85417"
style="fill:#008000;stroke:none;stroke-width:0.3">Получили:</tspan></text>
<text
xml:space="preserve"
style="font-size:6.35px;line-height:normal;font-family:'DejaVu Sans Mono';-inkscape-font-specification:'DejaVu Sans Mono';text-decoration:none;text-decoration-line:none;text-decoration-color:#000000;letter-spacing:0px;word-spacing:0px;fill:#000000;stroke-width:0.3;stroke-linecap:round;stroke-linejoin:bevel;stop-color:#000000"
x="-39.6875"
y="97.895836"
id="text1634"><tspan
sodipodi:role="line"
id="tspan1632"
x="-39.6875"
y="97.895836"
style="stroke-width:0.3">Время</tspan></text>
<path
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:none;fill-opacity:1;stroke:#000000;stroke-width:0.3;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;stop-color:#000000;stop-opacity:1"
d="m -34.395832,111.125 h 5.291666 v 402.16666 h 5.291666 l -7.937498,7.9375 -7.9375,-7.9375 h 5.291666 z"
id="path1636" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

View file

@ -0,0 +1,52 @@
# Общая структура
![](_resources/Архитектура%20-%20общая%20схема.png)
Основной код приложения написан на TypeScript и фреймворке NestJS (это такой Angular для backend-а)
Монолитное приложение включает в своём составе:
- основная функциональность
- кеш и промежуточный коллектор для данных на couchdb
- http api для внешних интеграций
- webhook-и и websocket-ы
- telegram-бот - есть возможность для реализации новых интеграций с другими чат-ботами
- cron-task-и для выполнения повторяющихся задач
- frontend server-side на простой шаблонизации
- frontend на react
- специальный плагин для lowcode платформы n8n
# Основной процесс
![](_resources/Архитектура%20-%20основной%20процесс.png)
Двигателем приложения служит redmine-issue-event-emitter - это основной процесс поступления данных в Pinkmine.
1. Подписка на источники событий - по email, через cron-таски, через rss. Позволяет обнаруживать факты изменений в задачах в redmine. ([подробнее](Как%20это%20работает/Как%20работают%20стратегии%20синхронизации%20redmine%20и%20pinkmine.md))
2. Redmine Issue Event Emitter - это очередь для синхронизации данных из redmine. На выход кладётся номер задачи, на выходе данные полученные с помощью redmine api. Очередь обеспечивает бережное отношение к боевому redmine, так как позволяет выполнять выгрузки не выходя за выставленный лимит обращений. ([подробнее про работу очереди](Как%20это%20работает/Как%20работает%20очередь%20загрузки%20задач.md) и [подробнее про загрузку задач из redmine](Как%20это%20работает/Как%20происходит%20загрузка%20задачи%20из%20Redmine.md))
3. Преобразование задачи - это набор вспомогательных обработчиков. Позволяет при получении данных произвести попутно полезные изменения, например, привести текстовые теги к массиву, определить текущего ответственного за задачу, обнаружить в описании или в комментариях ссылки на gitlab на MR-ы, в т.ч. можно реализовать специализированный обработчик. ([подбронее](./Как%20это%20работает/Как%20происходит%20преобразование%20задачи.md))
4. Сохранение задачи в кеш в CouchDB ([подробнее](./Как%20это%20работает/Сохранение%20задачи%20в%20кеш%20CouchDB.md))
5. Анализ изменений в задаче - это сравнение предыдущего состояния задачи с новым. Позволяет организовать процессы связанные с жизненным циклом самой задачи и, например, разослать уведомления с учётом происходящих изменений ([подробнее](./Как%20это%20работает/Анализ%20изменений%20в%20задаче.md))
6. Сохранение событий изменения в журнал - вспомогательная коллекция данных ([подробнее](./Как%20это%20работает/Анализ%20изменений%20в%20задаче.md))
# Точки расширения
Платформа Pinkmine предполагает в т.ч. реализацию конкретных задач в рамках основного кода. Допускается реализация специализированных внутренних сервисов.
В рамках ядра redmine-issue-event-emitter:
- новые стратегии получения задач из Redmine
- специализированные обработчики задач для отдельных проектов
- интеграция событий в новые процессы - чат-боты или простая передача данных с помощью webhook-ов и websocket-ов
В рамках расширенной платформы pinkmine:
Благодаря CouchDB стало возможным предоставить api для поиска задач с более гибкими условиями для выборки чем во встроенном в redmine api. Можно использовать в выборках комбинированные выражения через "и"/"или". Богатый набор операторов - "есть значение", "нет значения", "больше", "меньше", "равно", "поиск по регулярному выражению". Для поиска можно пользоваться расширенными данными.
CouchDB позволяет выгружать для работы и анализа больший объём данных чем основной api redmine.
Webhook-и и websocket-ы могут сделать api ещё более гибким.
В рамках реализованного telegram chat бота можно дописывать логику работы под узкоспециализированные задачи.
С помощью фреймворка NestJS можно сделать сервисы для решения узких задач. Например, сделать cron-task-у с аналитической выгрузкой с сохранением промежуточных результатов во вспомогательную коллекцию в CouchDB и предостатить уже к ней доступ через http api и сделать человеко-читаемое представление с помощью шаблона.

View file

@ -0,0 +1,64 @@
Как было описано в документе [Сохранение задачи в кеш CouchDB](./Сохранение%20задачи%20в%20кеш%20CouchDB.md) специфика работы с данными из issue такова что есть два состояния - предыдущее и текущее состояние.
Значит есть возможность сравнения двух состояний и построение дополнительной логики на основе сравнения.
Это позволило реализовать рассылку уведомлений при обнаружении в задачах изменений.
Функции сохранения данных в CouchDB с вычислением изменений реализована в `libs/event-emitter/src/issue-cache-writer/redmine-issues-cache-writer.service.ts`.
Затем с помощью пайплайнов rxjs реализован дополнительный анализ изменений:
1. `src/notifications/personal-notifications.service.ts` - поиск упоминаний пользователей redmine в комментариях для пересылки личных уведомлений в чат-бот
2. `src/notifications/status-change-notifications.service.ts` - поиск изменений статусов для рассылки уведомлений согласно изменениям ответственного за дальнейшую работу над задачей
3. `src/changes-cache-writer/changes-cache-writer.service.ts` - сохранение изменений в дополнительный журнал активностей в коллекцию в CouchDB
Сам пайплайн rxjs определяется в инициализирующей функции в `src/app.module.ts`.
Для правильности работы функций анализа нужно выполнить правильно конфигугирование.
Статусы используемые в вашем экземпляре redmine определяются в конфигурационном файле `configs/redmine-statuses-config.jsonc`. Они имеет следующий вид:
```json
[
{
"id": 1,
"name": "New"
},
{
"id": 2,
"name": "In Progress"
},
{
"id": 3,
"name": "Closed",
"is_closed": true
}
// ...
]
```
Идентификаторы и name должны соответствовать статусам настроенным в основном redmine.
Правила для определения изменений в статусах определяются с помощью конфигурационного файла `configs/redmine-status-changes-config.jsonc`. Он имеет следующий вид:
```json
[
{
"default": false,
"from": "New",
"to": "In Progress",
"messages": [
{
"recipient": "<field_name>",
// Handlebars - template engine
"changes_message": "{{qa.name}} got issue #{{issue_id}} after development {{dev.name}}",
"notification_message": "<a href=\"{{ issue_url }}\">{{ issue_tracker }} #{{ issue_id }}</a> {{ issue_subject }}:\n{{dev.name}} finished development. You can test issue.\n\n{{journal_note}}"
}
// ...
]
}
// ...
]
```
Это набор правил для формирования уведомлений и сообщений для журнала изменений. Поля "from" и "to" - указывают на возможные статусы задачи. "messages" - это набор сообщений. Тут можно определить варианты сообщений для различных получателей задаваемых полем "recipient", а тексты сообщений с помощью шаблонизатора [Handlebars](https://handlebarsjs.com).

View file

@ -0,0 +1,6 @@
Это обращение к стандартной функции через http api по следующему шаблону:
`<redmine_url>/issues/<issue_number>.json?include=children,journals,relations`
Подробное описание параметров api можно найти в официальной фокументации на странице [Rest_Issues](https://www.redmine.org/projects/redmine/wiki/Rest_Issues)

View file

@ -0,0 +1,41 @@
Для разрешения некоторых проблем напрашивалось попутно после получения данных об issue из redmine делать преобразования
Примеры:
1. Преобразовать дату+вермя из текстового формата в числовой для возможности фильтрации данных с помощью сравнивающих операторов - больше и меньше.
2. Привести текстовое значение тегов к массиву для фильтрации данных с помощью оператора "$in".
3. Можно извлечь из описания и комментариев ссылки на MR-ы в gitlab-е.
4. Определить текущего ответственного за задачу по правилам соответствия статусу одному из полей - "Назначено", "Code Reviewer", "Quality Assurance".
Через конфигурационный файл уже не решить настройку, т.к. за преобразования задач отвечают функции - а если точнее то классы с реализацией интерфейса `IssueEnhancerInterface`. Это делается в коде проекта Pinkmine. Примеры реализаций в файлах:
- `libs/event-emitter/src/issue-enhancers/timestamps-enhancer.ts` - преобразует дату+время из текстового формата в числовой
- `libs/event-emitter/src/issue-enhancers` - в папке ещё несколько универсальных примеров (не зависящих от экземпляра redmine)
- `src/issue-enhancers` - в папке лежат дополнительные примеры с функциями уже привязанными к рабочему redmine и специфичные для рабочих проектов
Чтобы эти функции отрабатывали налету после получения данных из redmine и перед сохранением в кеш, то нужно при начальной инициализации приложения (`src/app.module.ts` > `AppModule.onModuleInit`) указать набор реализаций `IssueEnhancerInterface`. Упрощённый код, отражающий суть:
```ts
export class AppModule {
// ...
onModuleInit() {
// ...
this.enhancerService.addEnhancer([
this.timestampEnhancer,
this.customFieldsEnhancer,
this.currentUserEnhancer,
this.issueUrlEnhancer,
this.categoryMergeToTagsEnhancer,
this.calendarEnhancer,
]);
// ...
}
// ...
}
```
Схематически процесс можно проиллюстрировать так:
![Преобразования задач](../_resources/Преобразования%20задач.png)
Выполнение преобразований данных до сохранения в кеш в CouchDB позволяет в последствии использовать дополнительные данные для выборки задач

View file

@ -0,0 +1,22 @@
Это стандартный паттерн:
1. берём максимум N номеров задач раз в M секунд,
2. передаём их в функцию для синхронизаицации
1. выгружаем данные о задаче из redmine api
2. пишем полученные данные во внутренний кеш
Очередь нужна для бережного отношения к боевому redmine, потому что не загрузит CPU на сервере сотнями одновременных параллельных запросов.
Настройка очереди производится в конфигурационном файле `configs/issue-event-emitter-config.jsonc` в секции `issueChangesQueue`:
```json
{
// ...
"issueChangesQueue": {
"updateInterval": 5000, // 5 sec
"itemsLimit": 3
}
// ...
}
```

View file

@ -0,0 +1,106 @@
# Стратегия по email
![](../_resources/Папка%20в%20почте%20с%20уведомлениями%20из%20Redmine.png)
1. В задаче в redmine делается любое изменение - смена статуса, обновление описания, добавление нового комментария.
2. Redmine отправляет сообщение о факте обновления задачи по email
3. Pinkmine читает новые письма протоколу imap, находит в заголовках номер задачи с помощью регулярного выражения
4. Pinkmine помещает номер задачи в очередь для выполнения обновления
Параметры работы стратегии задаются в конфигурационном файле `configs/issue-event-emitter-config.jsonc` в секции `mailListener`:
```json
{
// ...
"mailListener": {
// регулярное выражение для определения номера задачи в заголовке письма:
"issueNumberParser": "\\b(?<=#)\\d+\\b",
// параметры для доступа к почте через протокол imap:
"imapSimpleConfig": {
"imap": {
"user": "",
"password": "",
"host": "",
"port": 143,
// tls: true,
"autotls": "always",
"authTimeout": 5000
}
},
// интервал через который происходит проверка почты:
"updateInterval": 180000, // 3 min
// имя папки в которой следует искать входящие письма
"boxName": "INBOX"
},
// ...
}
```
# Стратегия через cron-таски
![Преднастроенный фильтр в Redmine](../_resources/Преднастроенный%20фильтр%20в%20Redmine.png)
1. Pinkmine загружает данные из преднастроенных query-запросов в redmine в формате csv. Выбирает данные по двум полям - id задачи и "обновлено" (дата+время обновления задачи)
2. Pinkmine проверяет какие задачи в своём кеше (CouchDB) уже успели устареть
3. Номера устаревших задач кладёт в очередь для дальнейшего обновления
Параметры работы стратегии задаются в конфигурационном файле `configs/issue-event-emitter-config.jsonc` в секциях `csvListener` и `rootIssueListener`:
```json
{
// ...
// таски для синхронизации по преднастроенным запросам:
"csvListener": {
"tasks": [
{
// расписание для выполнения задачи (синтаксис crontab)
"schedule": "* * * * *",
// поле в csv-файле с датой и временем обновления задачи
"updatedAtFieldName": "Обновлено",
// формат даты и времени
"dateTimeFormat": "dd.MM.yyyy HH:mm",
// ссылки на предстароенные запросы выборки задач в формате csv
"csvLinks": [
"https://<redmine_host>/projects/proj/issues.csv?utf8=%E2%9C%93&query_id=2"
]
}
]
},
// таски для синхронизации корневых и суммирующих задач:
"rootIssueListener": {
"tasks": [
{
// расписание для выполнения задачи (синтаксис crontab)
"schedule": "15 6,12 * * *",
// номера задач
"rootIssues": [
1,
3,
7,
11
]
}
]
},
// ...
}
```
Эта стратегия позволяет догружать данные, которые не пришли по email рассылке: закрытые задачи, shady-mode, изменение структуры дерева при смене привязки к родительской задаче и т.п.

View file

@ -0,0 +1,32 @@
CouchDB - это nosql субд. Основная концепция CouchDB - это коллекции данных (json) с возможностью гибкого поиска по ним.
Важная особенность CouchDB - это отсутствие механизма транзакций. Гарантия согласованности записи обеспечивается с помощью контроля ревизии.
Документы в CouchDB имеют версионирование, аналогичное тому, как это было бы в обычной системе контроля версий, такой как Subversion. Если вы хотите изменить значение в документе, вы создаете полностью новую версию этого документа и сохраняете ее поверх старой. После выполнения этого вы получите две версии одного и того же документа, одну старую и одну новую.
Как это обеспечивает улучшение по сравнению с блокировками? Рассмотрим набор запросов, желающих получить доступ к документу. Первый запрос считывает документ. Пока он обрабатывается, второй запрос изменяет документ. Поскольку второй запрос включает совершенно новую версию документа, CouchDB может просто добавить его в базу данных, не дожидаясь завершения запроса на чтение.
Когда третий запрос захочет прочитать тот же документ, CouchDB укажет ему на новую версию, которая только что была написана. В течение всего этого процесса первый запрос все еще может читать исходную версию.
Запрос на чтение всегда будет отображать самый последний снимок вашей базы данных на момент начала запроса.
![Сохранение в CouchDB](../_resources/Сохранение%20в%20CouchDB.png)
Redmine Issue Event Emitter всегда для записи нового состояния задачи должен прочитать предыдущее состояние. Это обязательно нужно для корректного определения последней ревизии. Одновременно для дальнейшего анализа станет доступно два состояния - предыдущее и текущее. Благодаря этой особенности работы с CouchDB можно проводить дополнительный [анализ произошедших в задаче изменений](./Анализ%20изменений%20в%20задаче.md).
Настройка доступа к CouchDB делается в конфигурационном файле `configs/issue-event-emitter-config.jsonc` в секции `couchDb`:
```json
{
// ...
"couchDb": {
"url": "http://admin:password@localhost:5984",
"dbs": {
"users": "redmine_users",
"issues": "redmine_issues",
"dashboards": "dashboards"
}
}
// ...
}
```

View file

@ -0,0 +1,408 @@
# Создание кастомного виджета для дашборда
В проекте представлено несколько готовых виджетов для дашбордов. Главная фича дашбордов - возможность добавления любых кастомных виджетов на основе существующих React компонентов или разработки собственных компонентов.
Чтобы добавить собственный виджет нужно
- добавить data-loader на backend-е
- добавить React компонент для виджета на frontend-е
data-loader и react-компонент свяжутся через совпадающие поле `type`.
Давайте разберём подробнее на примере создания виджета предстоящих событий.
![Виджет с календарём предстоящих событий.png](_resources/Виджет%20с%20календарём%20предстоящих%20событий.png)
## Data loader на backend-е
Выберем расположение файла для класса data-loader-а на backend-е.
Data-loader для нового виджета можно расположить где угодно.
1. Для единообразия его можно расположить в стандартном месте с универсальными виджетами в папке `libs/event-emitter/src/dashboards/widget-data-loader`
2. Либо если виджет ускоспециализирован под нужды вашего проекта, то можно сделать отдельную папку под ваш проект: `src/<PROJECT_NAME>/dashboards/widget-data-loader`
Рассматриваемый виджет будет универсальным, поэтому файл сохраним в `libs/event-emitter/src/dashboards/widget-data-loader/calendar.widget-data-loader.service.ts`:
```ts
import { Injectable } from '@nestjs/common';
import { WidgetDataLoaderInterface } from '../widget-data-loader-interface';
import { Result, AppError } from '@app/event-emitter/utils/result';
@Injectable()
export class CalendarWidgetDataLoaderService
implements WidgetDataLoaderInterface<any, any, any>
{
// TODO: Добавить конструктор для подключения
// дополнительных зависимостей
isMyConfig(dataLoaderParams: any): boolean {
return true;
}
load(
dataLoaderParams: any,
dashboardParams: any,
): Promise<Result<any, AppError>> {
// TODO: Логика для загрузки данных
throw new Error('Method not implemented.');
}
}
```
В фреймворках для классических случаев по концепции MVC делают сервисы для реализации в них логики и контроллеры для открытия ендпоинтов для передачи данных пользователям и на frontend приложения.
К представленному выше `DataLoaderService` можно относиться как к контроллеру в концепции MVC, который обратиться к более низкоуровневому сервису для получения данных, а так же передаст эти результаты уже в виджет на frontend-е.
Сервис для получения данных уже был разработан, и остаётся его подключить к data-loader-у класс `CalendarService` через конструктор и получить с его помощью нужные данные вызвав метод `CalendarService.getRawData`:
```ts
@Injectable()
export class CalendarWidgetDataLoaderService
implements WidgetDataLoaderInterface<DataLoaderParams, any, IssueAndEvent[]>
{
constructor(private calendarService: CalendarService) {}
isMyConfig(): boolean {
return true;
}
async load(
dataLoaderParams: DataLoaderParams,
): Promise<Result<IssueAndEvent[], AppError>> {
let data: IssueAndEvent[];
try {
data = await this.calendarService.getRawData(
dataLoaderParams.filter,
dataLoaderParams.period * 24 * 60 * 60 * 1000,
);
return success(data);
} catch (ex) {
return fail(createAppError(ex.message ? ex.message : 'UNKNOWN_ERROR'));
}
}
}
```
Остаётся зарегистрировать data-loader в коллекцию доступных виджетов на стороне backend-а и задать ему свой уникальный `type`.
> Архитектура предполагает кроме реализации `WidgetDataLoaderInterface` ещё и реализацию собственно виджета `WidgetInterface`, но последнее можно упросить с помощью вызова фабричного метода `createInteractiveWidget` - он создаст экземпляр `InteractiveWidget` для реализованного ранее data-loader-а. Более подробно можно ознакомитсья с разными типами виджетов, как они устроены в разделе [[Архитектура дашбордов]].
Зарегистрировать новый тип виджета можно в коллекции `libs/event-emitter/src/dashboards/widgets-collection.service.ts` для этого в конструктор добавить такой код:
```ts
createInteractiveWidget(
this.calendarWidgetDataLoaderService,
'calendar_next_events',
)
```
**Важно:** проделать необходимые изменения в соответствии с требованиями базового фреймворка NestJS. Сюда входят требования к инъекции зависимостей в конструктор из сервисов доступных через модули `libs/event-emitter/src/event-emitter.module.ts` и `src/app.module.ts` и включение новых сервисов в эти модули. Подробнее об этом можно прочитать в документации [NestJS - Providers](https://docs.nestjs.com/providers).
На backend-е всё готово. Переходим к самому интересному - frontend-у
## Виджет на frontend-е
Начнём с непосредственно компонента. Виджеты лежат в папке `frontend/src/dashboard/widgets`. Добавим туда же новый виджет `frontend/src/dashboard/widgets/calendar-next-events.tsx`:
```tsx
import React from 'react';
export const CalendarNextEvents = (): JSX.Element => {
return <></>;
};
```
Этот пустой компонент можно сразу добавить в фабрику виджетов дашборда на frontend-е:
`frontend/src/dashboard/widgets/widget-factory.tsx`:
```tsx
import React from 'react';
import { CalendarNextEvents } from './calendar-next-events';
// ...
export const WidgetFactory = observer((props: Props): JSX.Element => {
// ...
if (props.store.type === 'calendar_next_events') {
return <CalendarNextEvents store={props.store} />;
}
// ...
});
```
**Важно!** Чтобы `type` совпадало с объявленным типом виджета на backend-е. Объявление на backend-е было сделано в файле `libs/event-emitter/src/dashboards/widgets-collection.service.ts`.
Теперь можно снова вернуться к виджету `calendar-next-events.tsx`.
Для управления состоянием компонентов на стороне frontend-а предполагается использование [mobx-state-tree](https://mobx-state-tree.js.org/), поэтому для компоненты можно определить следующий стор с массивом данных event+issue:
```ts
// Описание основных и вспомогательных моделей данных для календаря:
export const CalendarEvent = types.model({
from: types.string,
fromTimestamp: types.number,
to: types.string,
toTimestamp: types.number,
fullDay: types.boolean,
description: types.string,
});
export type ICalendarEvent = Instance<typeof CalendarEvent>;
export const FormattedDateTime = types.model({
fromDate: types.string,
fromTime: types.string,
toDate: types.string,
toTime: types.string,
interval: types.string,
});
export type IFormattedDateTime = Instance<typeof FormattedDateTime>;
export const IssueAndEvent = types.model({
issue: types.frozen<any>(),
event: CalendarEvent,
});
export type IIssueAndEvent = Instance<typeof IssueAndEvent>;
type InDay = {
order: number;
relativeDate: string | null;
formattedDate: string;
events: string[];
};
export const DATE_FORMAT = 'dd.MM.yyyy';
export const TIME_FORMAT = 'HH:mm';
function getEventKey(e: IIssueAndEvent): string {
const description: string = e.event.description.replaceAll(/\s+/g, '_');
const fromKey = String(e.event.fromTimestamp);
const toKey = String(e.event.toTimestamp);
return `${e.issue.id}-${fromKey}-${toKey}-${description}`;
}
function getFormattedDateTime(
issueAndEvent: IIssueAndEvent,
): IFormattedDateTime {
const from = DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
const to = DateTime.fromMillis(issueAndEvent.event.toTimestamp);
const fromDate: string = from.isValid ? from.toFormat(DATE_FORMAT) : '-';
const fromTime: string = from.isValid ? from.toFormat(TIME_FORMAT) : '-';
const toDate: string = to.isValid ? to.toFormat(DATE_FORMAT) : '-';
const toTime: string = to.isValid ? to.toFormat(TIME_FORMAT) : '-';
let interval: string;
if (issueAndEvent.event.fullDay) {
interval = 'весь день';
} else if (toTime != '-') {
interval = `${fromTime} - ${toTime}`;
} else {
interval = `${fromTime}`;
}
return FormattedDateTime.create({
fromDate: fromDate,
fromTime: fromTime,
toDate: toDate,
toTime: toTime,
interval: interval,
});
}
function getRelativeDate(issueAndEvent: IIssueAndEvent): string | null {
const from = Luxon.DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
return from.toRelativeCalendar();
}
function getStartOfDayTimestamp(issueAndEvent: IIssueAndEvent): number {
const from = Luxon.DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
return from.startOf('day').toMillis();
}
/** Стор данных для виджета "Календарь следующих событий" */
export const EventsStore = types
.model({
events: types.array(IssueAndEvent),
})
.views((self) => {
return {
/** Геттер мапы событий в формате eventKey -> issue+event */
eventsMap: (): Record<string, IIssueAndEvent> => {
return self.events.reduce((acc, issueAndEvent) => {
const key = getEventKey(issueAndEvent);
acc[key] = issueAndEvent;
return acc;
}, {} as Record<string, IIssueAndEvent>);
},
/**
* Геттер сгруппированных по дням данных.
*
* Ключ группы определяется по timestamp-у указывающему на начало дня
*
* Дополнительные поля relativeDate и formattedDate для отображения в виджете подробной
* информации об этой группе событий
*/
orderedByDates: (): InDay[] => {
const res: Record<number, InDay> = self.events.reduce(
(acc, issueAndEvent) => {
const order = getStartOfDayTimestamp(issueAndEvent);
const formattedDate = DateTime.fromMillis(
issueAndEvent.event.fromTimestamp,
).toFormat(DATE_FORMAT);
if (!acc[order]) {
acc[order] = {
order: order,
relativeDate: getRelativeDate(issueAndEvent),
formattedDate: formattedDate,
events: [],
};
}
const key = getEventKey(issueAndEvent);
acc[order].events.push(key);
return acc;
},
{} as Record<number, InDay>,
);
return Object.values(res).sort((a, b) => a.order - b.order);
},
/**
* Мапа событий с отформатированными значениями даты и времени
*
* * Ключ - это ключ события
* * Значение - вспомогательная информация для человеко-читаемого вывода даты и времени
*/
formattedDateTimes: (): Record<string, IFormattedDateTime> => {
const res: Record<string, IFormattedDateTime> = self.events.reduce(
(acc, event) => {
const key = getEventKey(event);
acc[key] = getFormattedDateTime(event);
return acc;
},
{} as Record<string, IFormattedDateTime>,
);
return res;
},
};
})
.actions((self) => {
return {
/** Сеттер основных данных */
setEvents: (events: any): void => {
self.events = events;
},
};
});
export type IEventsStore = Instance<typeof EventsStore>;
```
Этот стор будет принимать данные получаемые с backend-а через action "setEvents" и с помощью вспомогательных геттеров предоставлять нужную информацию для вывода в виджете.
Теперь остаётся в react-компоненте CalendarNextEvents добавить использование описанного выше стора и сделать рендер данных:
```tsx
/**
* Компонент для отображения данных из стора EventsStore
*
* @see {EventsStore}
*/
export const CalendarList = observer(
(props: { store: IEventsStore }): JSX.Element => {
const list = props.store.orderedByDates().map((events) => {
const keyOfGroup = `${events.order}-${events.relativeDate}`;
const item = (
<div key={keyOfGroup}>
<p title={events.formattedDate}>{events.relativeDate}:</p>
<ul>
{events.events.map((keyOfEvent) => {
const events = props.store.eventsMap();
const formatted = props.store.formattedDateTimes();
if (!events[keyOfEvent] && !formatted[keyOfEvent]) return <></>;
const issue = events[keyOfEvent].issue;
return (
<li key={keyOfEvent}>
{formatted[keyOfEvent].interval}:{' '}
<IssueHref
id={issue.id}
subject={events[keyOfEvent].event.description}
tracker={issue.tracker.name}
url={issue.url.url}
/>
</li>
);
})}
</ul>
</div>
);
return item;
});
return <>{list}</>;
},
);
export type Props = {
store: DashboardStoreNs.IWidget;
};
/**
* Основной компонент календаря
*
* Он нужен для преобразования стора абстрактного виджета в стор специфичный для
* календаря
*/
export const CalendarNextEvents = observer((props: Props): JSX.Element => {
const calendarListStore = EventsStore.create();
onSnapshot(props.store, (storeState) => {
if (storeState.data) {
calendarListStore.setEvents(storeState.data);
}
});
return (
<>
<CalendarList store={calendarListStore} />
</>
);
});
```
## Проверка
Если всё было сделано верно, то в дашбордах станет доступен новый виджет "calendar_next_events".
Можно проверить на тестовом дашборде с конфигурацией следующего вида:
```json
{
"widgets": [
{
"id": "test-calendar",
"title": "Тест календаря",
"type": "calendar_next_events",
"collapsed": true,
"dataLoaderParams": {
"filter": {
"selector": {
"project.name": "proj"
},
"limit": 10
},
"period": 30
}
}
],
"title": "Test"
}
```
И убедиться что данные соответствуют указанным данным в задачах Redmine:
![Виджет с календарём предстоящих событий получившийся результат.png](_resources/Виджет%20с%20календарём%20предстоящих%20событий%20-%20получившийся%20результат.png)

107
docs/Установка.md Normal file
View file

@ -0,0 +1,107 @@
# Установка
Инструкция для установки приложения
## Требования к системе
Требования к окружению для сборки и запуска приложения:
- CouchDB в качестве хранилища данных
- NodeJS для сборки и запуска backend-а и frontend-а
Доступы к внешним системам:
- Токен для подключения telegram-бота
- Токен для доступа к API Redmine
## Подготовка и запуск couchdb
CouchDB необходим для хранения данных.
Для запуска можно воспользоваться любым удобным сопособом.
Пример с помощью docker-compose
1. `docker-compose.yml`:
```yaml
version: '3.1'
services:
couchdb:
image: apache/couchdb:latest
restart: always
ports:
- 127.0.0.1:5984:5984
volumes:
- ./couchdb-data:/opt/couchdb/data
environment:
- COUCHDB_USER
- COUCHDB_PASSWORD
```
2. Так же нужно указать имя и пароль с помощью файла `.env`:
```
COUCHDB_USER=admin
COUCHDB_PASSWORD=password
```
3. Создать папку `couchdb-data` для вольюма определённого для контейнера в `docker-compose.yml`:
```shell
mkdir couchdb-data
```
4. Для запуска воспользоваться командой:
```shell
docker-compose up -d
```
## Установка зависимостей для backend-а и frontend-а
Backend и frontend написаны на языке typescript. Для загрузки и запуска необходимо выполнить:
```shell
cd $PROJECT_DIR && npm install
cd $PROJECT_DIR/frontend && npm install
```
## Настройка
Нужно скопировать все конфигурационные файлы `CONFIG_NAME.jsonc.dist` в `CONFIG_NAME.jsonc`:
```shell
cd $PROJECT_DIR/configs/
cp calendar-enhancer.jsonc.dist calendar-enhancer.jsonc
cp current-user-rules.jsonc.dist current-user-rules.jsonc
cp eccm-config.jsonc.dist eccm-config.jsonc
cp issue-event-emitter-config.jsonc.dist issue-event-emitter-config.jsonc
cp main-config.jsonc.dist main-config.jsonc
cp redmine-status-changes-config.jsonc.dist redmine-status-changes-config.jsonc
cp redmine-statuses-config.jsonc.dist redmine-statuses-config.jsonc
cp simple-kanban-board-config.jsonc.dist simple-kanban-board-config.jsonc
```
Или можно взять за основу файлы из папки `$PROJECT_DIR/configs/configs.example/` или в качестве примеров для заполнения. В этих файлах можно обратить внимание на строчки с TODO и рекоментациями что нужно указать в конфигурационном файле. Там могут встречаться такие рекомендации как:
* Указать host, port, username и password для доступа к couchdb
* Указать token от бота telegram
## Запуск приложения
1. Нужно сделать сборку frontend-а:
```shell
cd $PROJECT_DIR/frontend/ && npm run build
```
2. Сделать сборку или можно просто запустить backend:
```shell
cd $PROJECT_DIR/ && npm run start
```
Если нужно запустить приложение с явным указанием порта, то можно воспользоваться переменной окружения `PORT`.

View file

@ -113,6 +113,7 @@ export const Editor = observer((props: Props): JSX.Element => {
defaultValue={editorValue}
value={editorValue}
onChange={(value) => setEditorValue(value || '')}
options={{ tabSize: 2, detectIndentation: true }}
></MonacoEditor>
</div>
</div>

View file

@ -0,0 +1,231 @@
import { observer } from 'mobx-react-lite';
import { Instance, onSnapshot, types } from 'mobx-state-tree';
import React from 'react';
import * as Luxon from 'luxon';
import * as DashboardStoreNs from '../dashboard-store';
import { DateTime } from 'luxon';
import { IssueHref } from '../../misc-components/issue-href';
// Описание основных и вспомогательных моделей данных для календаря:
export const CalendarEvent = types.model({
from: types.string,
fromTimestamp: types.number,
to: types.string,
toTimestamp: types.number,
fullDay: types.boolean,
description: types.string,
});
export type ICalendarEvent = Instance<typeof CalendarEvent>;
export const FormattedDateTime = types.model({
fromDate: types.string,
fromTime: types.string,
toDate: types.string,
toTime: types.string,
interval: types.string,
});
export type IFormattedDateTime = Instance<typeof FormattedDateTime>;
export const IssueAndEvent = types.model({
issue: types.frozen<any>(),
event: CalendarEvent,
});
export type IIssueAndEvent = Instance<typeof IssueAndEvent>;
type InDay = {
order: number;
relativeDate: string | null;
formattedDate: string;
events: string[];
};
export const DATE_FORMAT = 'dd.MM.yyyy';
export const TIME_FORMAT = 'HH:mm';
function getEventKey(e: IIssueAndEvent): string {
const description: string = e.event.description.replaceAll(/\s+/g, '_');
const fromKey = String(e.event.fromTimestamp);
const toKey = String(e.event.toTimestamp);
return `${e.issue.id}-${fromKey}-${toKey}-${description}`;
}
function getFormattedDateTime(
issueAndEvent: IIssueAndEvent,
): IFormattedDateTime {
const from = DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
const to = DateTime.fromMillis(issueAndEvent.event.toTimestamp);
const fromDate: string = from.isValid ? from.toFormat(DATE_FORMAT) : '-';
const fromTime: string = from.isValid ? from.toFormat(TIME_FORMAT) : '-';
const toDate: string = to.isValid ? to.toFormat(DATE_FORMAT) : '-';
const toTime: string = to.isValid ? to.toFormat(TIME_FORMAT) : '-';
let interval: string;
if (issueAndEvent.event.fullDay) {
interval = 'весь день';
} else if (toTime != '-') {
interval = `${fromTime} - ${toTime}`;
} else {
interval = `${fromTime}`;
}
return FormattedDateTime.create({
fromDate: fromDate,
fromTime: fromTime,
toDate: toDate,
toTime: toTime,
interval: interval,
});
}
function getRelativeDate(issueAndEvent: IIssueAndEvent): string | null {
const from = Luxon.DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
return from.toRelativeCalendar();
}
function getStartOfDayTimestamp(issueAndEvent: IIssueAndEvent): number {
const from = Luxon.DateTime.fromMillis(issueAndEvent.event.fromTimestamp);
return from.startOf('day').toMillis();
}
/** Стор данных для виджета "Календарь следующих событий" */
export const EventsStore = types
.model({
events: types.array(IssueAndEvent),
})
.views((self) => {
return {
/** Геттер мапы событий в формате eventKey -> issue+event */
eventsMap: (): Record<string, IIssueAndEvent> => {
return self.events.reduce((acc, issueAndEvent) => {
const key = getEventKey(issueAndEvent);
acc[key] = issueAndEvent;
return acc;
}, {} as Record<string, IIssueAndEvent>);
},
/**
* Геттер сгруппированных по дням данных.
*
* Ключ группы определяется по timestamp-у указывающему на начало дня
*
* Дополнительные поля relativeDate и formattedDate для отображения в виджете подробной
* информации об этой группе событий
*/
orderedByDates: (): InDay[] => {
const res: Record<number, InDay> = self.events.reduce(
(acc, issueAndEvent) => {
const order = getStartOfDayTimestamp(issueAndEvent);
const formattedDate = DateTime.fromMillis(
issueAndEvent.event.fromTimestamp,
).toFormat(DATE_FORMAT);
if (!acc[order]) {
acc[order] = {
order: order,
relativeDate: getRelativeDate(issueAndEvent),
formattedDate: formattedDate,
events: [],
};
}
const key = getEventKey(issueAndEvent);
acc[order].events.push(key);
return acc;
},
{} as Record<number, InDay>,
);
return Object.values(res).sort((a, b) => a.order - b.order);
},
/**
* Мапа событий с отформатированными значениями даты и времени
*
* * Ключ - это ключ события
* * Значение - вспомогательная информация для человеко-читаемого вывода даты и времени
*/
formattedDateTimes: (): Record<string, IFormattedDateTime> => {
const res: Record<string, IFormattedDateTime> = self.events.reduce(
(acc, event) => {
const key = getEventKey(event);
acc[key] = getFormattedDateTime(event);
return acc;
},
{} as Record<string, IFormattedDateTime>,
);
return res;
},
};
})
.actions((self) => {
return {
/** Сеттер основных данных */
setEvents: (events: any): void => {
self.events = events;
},
};
});
export type IEventsStore = Instance<typeof EventsStore>;
/**
* Компонент для отображения данных из стора EventsStore
*
* @see {EventsStore}
*/
export const CalendarList = observer(
(props: { store: IEventsStore }): JSX.Element => {
const list = props.store.orderedByDates().map((events) => {
const keyOfGroup = `${events.order}-${events.relativeDate}`;
const item = (
<div key={keyOfGroup}>
<p title={events.formattedDate}>{events.relativeDate}:</p>
<ul>
{events.events.map((keyOfEvent) => {
const events = props.store.eventsMap();
const formatted = props.store.formattedDateTimes();
if (!events[keyOfEvent] && !formatted[keyOfEvent]) return <></>;
const issue = events[keyOfEvent].issue;
return (
<li key={keyOfEvent}>
{formatted[keyOfEvent].interval}:{' '}
<IssueHref
id={issue.id}
subject={events[keyOfEvent].event.description}
tracker={issue.tracker.name}
url={issue.url.url}
/>
</li>
);
})}
</ul>
</div>
);
return item;
});
return <>{list}</>;
},
);
export type Props = {
store: DashboardStoreNs.IWidget;
};
/**
* Основной компонент календаря
*
* Он нужен для преобразования стора абстрактного виджета в стор специфичный для
* календаря
*/
export const CalendarNextEvents = observer((props: Props): JSX.Element => {
const calendarListStore = EventsStore.create();
onSnapshot(props.store, (storeState) => {
if (storeState.data) {
calendarListStore.setEvents(storeState.data);
}
});
return (
<>
<CalendarList store={calendarListStore} />
</>
);
});

View file

@ -5,6 +5,7 @@ import { observer } from 'mobx-react-lite';
import * as KanbanWidgetNs from './kanban';
import { DebugInfo } from '../../misc-components/debug-info';
import * as IssuesListNs from './issues-list';
import * as CalendarNextEventsNs from './calendar-next-events';
export type Props = {
store: Instance<typeof DashboardStoreNs.Widget>;
@ -21,6 +22,10 @@ export const WidgetFactory = observer((props: Props): JSX.Element => {
return <IssuesListNs.IssuesList store={props.store} />;
}
if (type === 'calendar_next_events') {
return <CalendarNextEventsNs.CalendarNextEvents store={props.store} />;
}
return (
<div>
<div>Unknown widget</div>

View file

@ -1,58 +1,54 @@
import { Controller, Get, Inject, Logger, Param, Query } from "@nestjs/common";
import { CalendarService } from "./calendar.service";
import { Controller, Get, Inject, Logger, Param, Query } from '@nestjs/common';
import { CalendarService } from './calendar.service';
import nano from 'nano';
import { UNLIMITED } from "../consts/consts";
import { UNLIMITED } from '../consts/consts';
@Controller('calendar')
export class CalendarController {
private logger = new Logger(CalendarController.name);
constructor(
@Inject('CALENDAR_SERVICE')
private calendarService: CalendarService
) {}
@Get()
async get(@Param('filter') filter: any): Promise<string> {
return await this.calendarService.getICalData(filter);
}
@Get('/simple')
async simple(
@Query('project') project?: string,
@Query('category') category?: string
): Promise<string> {
const andSection: any[] = [
{
"closed_on": {
"$exists": false
}
}
];
if (project) {
andSection.push({
"project.name": {
"$in": [
project
]
}
});
}
if (category) {
andSection.push({
"category.name": {
"$in": [
category
]
}
});
}
const query: nano.MangoQuery = {
selector: {
"$and": andSection
},
limit: UNLIMITED
};
return await this.calendarService.getICalData(query);
}
}
private logger = new Logger(CalendarController.name);
constructor(
@Inject('CALENDAR_SERVICE')
private calendarService: CalendarService,
) {}
@Get()
async get(@Param('filter') filter: any): Promise<string> {
return await this.calendarService.getICalData(filter);
}
@Get('/simple')
async simple(
@Query('project') project?: string,
@Query('category') category?: string,
): Promise<string> {
const andSection: any[] = [
{
closed_on: {
$exists: false,
},
},
];
if (project) {
andSection.push({
'project.name': {
$in: [project],
},
});
}
if (category) {
andSection.push({
'category.name': {
$in: [category],
},
});
}
const query: nano.MangoQuery = {
selector: {
$and: andSection,
},
limit: UNLIMITED,
};
return await this.calendarService.getICalData(query);
}
}

View file

@ -1,8 +1,8 @@
import { Injectable, Logger } from "@nestjs/common";
import { CalendarEvent } from "../models/calendar-event";
import { RedmineTypes } from "../models/redmine-types";
import { Injectable } from '@nestjs/common';
import { CalendarEvent } from '../models/calendar-event';
import { RedmineTypes } from '../models/redmine-types';
import * as Luxon from 'luxon';
import { IssuesService } from "../issues/issues.service";
import { IssuesService } from '../issues/issues.service';
import { randomUUID } from 'crypto';
/*
@ -36,57 +36,89 @@ END:VCALENDAR
*/
export type IssueAndEvent = {
issue: RedmineTypes.ExtendedIssue;
event: CalendarEvent;
issue: RedmineTypes.ExtendedIssue;
event: CalendarEvent;
};
@Injectable()
export class CalendarService {
private interval = 30 * 24 * 60 * 60 * 1000; // 30 days
constructor(public calendarEventsKey: string, private issuesService: IssuesService) {}
async getICalData(filter: any): Promise<string> {
const issues = await this.issuesService.find(filter);
const actualEvents = this.getActualEvents(issues);
const formattedEvents = actualEvents.map((event) => {
return this.generateICalendarEvent(event.issue, event.event);
}).filter((event) => {
return !!event;
});
const res = this.generateICalendar(formattedEvents);
return res;
}
private getActualEvents(issues: RedmineTypes.ExtendedIssue[]): IssueAndEvent[] {
const res: IssueAndEvent[] = [];
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
if (!issue[this.calendarEventsKey] || issue[this.calendarEventsKey].length <= 0) {
continue;
}
const events = issue[this.calendarEventsKey];
for (let j = 0; j < events.length; j++) {
const event = events[j];
if (this.actualEvent(event)) res.push({event: event, issue: issue});
}
}
return res;
}
private actualEvent(event: CalendarEvent): boolean {
const now = Luxon.DateTime.now().toMillis();
const from = now - this.interval;
const to = now + this.interval;
return Boolean(
(from <= event.fromTimestamp && event.fromTimestamp <= to) ||
(from <= event.toTimestamp && event.toTimestamp <= to)
);
}
private generateICalendarEvent(issue: RedmineTypes.Issue, data: CalendarEvent): string | null {
if (!data) return null;
return `BEGIN:VEVENT
private defaultInterval = 30 * 24 * 60 * 60 * 1000; // 30 days
constructor(
public calendarEventsKey: string,
private issuesService: IssuesService,
) {}
/**
* @param filter фильтр для первичной выборки данных из couchdb через nano.filter
* @param interval период в милисекундах
* @returns
*/
async getICalData(filter: any, interval?: number): Promise<string> {
const issues = await this.issuesService.find(filter);
const actualEvents = this.getActualEvents(issues, interval);
const formattedEvents = actualEvents
.map((event) => {
return this.generateICalendarEvent(event.issue, event.event);
})
.filter((event) => {
return !!event;
});
const res = this.generateICalendar(formattedEvents);
return res;
}
/**
* @param filter фильтр для первичной выборки данных из couchdb через nano.filter
* @param interval период в милисекундах
* @returns
*/
async getRawData(filter: any, interval?: number): Promise<IssueAndEvent[]> {
const issues = await this.issuesService.find(filter);
return this.getActualEvents(issues, interval);
}
private getActualEvents(
issues: RedmineTypes.ExtendedIssue[],
interval?: number,
): IssueAndEvent[] {
if (typeof interval !== 'number') interval = this.defaultInterval;
const res: IssueAndEvent[] = [];
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
if (
!issue[this.calendarEventsKey] ||
issue[this.calendarEventsKey].length <= 0
) {
continue;
}
const events = issue[this.calendarEventsKey];
for (let j = 0; j < events.length; j++) {
const event = events[j];
if (this.actualEvent(event, interval)) {
res.push({ event: event, issue: issue });
}
}
}
return res;
}
private actualEvent(event: CalendarEvent, interval: number): boolean {
const now = Luxon.DateTime.now().toMillis();
const from = now - interval;
const to = now + interval;
return Boolean(
(from <= event.fromTimestamp && event.fromTimestamp <= to) ||
(from <= event.toTimestamp && event.toTimestamp <= to),
);
}
private generateICalendarEvent(
issue: RedmineTypes.Issue,
data: CalendarEvent,
): string | null {
if (!data) return null;
return `BEGIN:VEVENT
UID:${randomUUID()}@example.com
DTSTAMP:${this.formatTimestamp(data.fromTimestamp, data.fullDay)}
ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
@ -94,20 +126,20 @@ DTSTART:${this.formatTimestamp(data.fromTimestamp, data.fullDay)}
DTEND:${this.formatTimestamp(data.toTimestamp, data.fullDay)}
SUMMARY:#${issue.id} - ${data.description} - ${issue.subject}
END:VEVENT`;
}
private formatTimestamp(timestamp: number, fullDay?: boolean): string {
let format: string = fullDay ? "yyyyMMdd" : "yyyyMMdd'T'HHmmss'Z'";
let datetime = Luxon.DateTime.fromMillis(timestamp);
if (!fullDay) datetime = datetime.setZone('utc');
return datetime.toFormat(format);
}
private generateICalendar(events: string[]): string {
return `BEGIN:VCALENDAR
}
private formatTimestamp(timestamp: number, fullDay?: boolean): string {
const format: string = fullDay ? 'yyyyMMdd' : "yyyyMMdd'T'HHmmss'Z'";
let datetime = Luxon.DateTime.fromMillis(timestamp);
if (!fullDay) datetime = datetime.setZone('utc');
return datetime.toFormat(format);
}
private generateICalendar(events: string[]): string {
return `BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//CalDAV Client//EN
${events.join("\n")}
${events.join('\n')}
END:VCALENDAR`;
}
}
}
}

View file

@ -0,0 +1,220 @@
import { BehaviorSubject } from 'rxjs';
import { EventsListener } from '../events/events-listener';
import { Injectable, Logger } from '@nestjs/common';
import { SchedulerRegistry } from '@nestjs/schedule';
import { CronJob } from 'cron';
import { randomUUID } from 'crypto';
import { ConfigService } from '@nestjs/config';
import { IssuesService } from '../issues/issues.service';
import { RedmineDataLoader } from '../redmine-data-loader/redmine-data-loader';
import { DateTime } from 'luxon';
import { RedmineTypes } from '../models/redmine-types';
export type CsvIssue = Record<string, any>;
export type Task = {
schedule: string;
updatedAtFieldName: string;
dateTimeFormat: string;
csvLinks: string[];
};
export type CsvListenerParams = {
tasks: Task[];
};
export type CsvIssuesStore = Record<number, CsvIssue>;
@Injectable()
export class CsvListenerFactory {
private csvListener: CsvListener;
constructor(
private configService: ConfigService,
private schedulerRegistry: SchedulerRegistry,
private issuesService: IssuesService,
private redmineDataLoader: RedmineDataLoader,
) {}
getCsvListener(): CsvListener {
if (!this.csvListener) {
const params = this.configService.get('csvListener');
this.csvListener = new CsvListener(
params,
this.schedulerRegistry,
'default_csv_listener',
this.issuesService,
this.redmineDataLoader,
);
}
return this.csvListener;
}
}
@Injectable()
export class CsvListener implements EventsListener {
private logger = new Logger(CsvListener.name);
issues: BehaviorSubject<number[]> = new BehaviorSubject([]);
constructor(
private params: CsvListenerParams,
private schedulerRegistry: SchedulerRegistry,
private jobPrefix: string,
private issuesService: IssuesService,
private redmineDataLoader: RedmineDataLoader,
) {
this.logger.log(
`Csv listener created with params - ${JSON.stringify(params)}`,
);
}
start(): void {
const tasks = this?.params?.tasks || [];
this.logger.debug(`Scheduling ${tasks.length} tasks`);
for (let i = 0; i < tasks.length; i++) {
const task = this.params.tasks[i];
const cronJobName = this.createCronJobName();
const cronJob = new CronJob(
task.schedule,
this.createLoader(task, cronJobName),
);
this.schedulerRegistry.addCronJob(cronJobName, cronJob);
cronJob.start();
}
}
stop(): void {
const jobs = this.schedulerRegistry.getCronJobs();
jobs.forEach((job, jobName) => {
if (this.isListenerCronJon(jobName)) {
job.stop();
this.schedulerRegistry.deleteCronJob(jobName);
}
});
}
private createCronJobName(): string {
return `${this.jobPrefix}_${randomUUID()}`;
}
private isListenerCronJon(name: string): boolean {
return name.startsWith(`${this.jobPrefix}_`);
}
private createLoader(task: Task, cronJobName: string): () => Promise<void> {
return async () => {
this.logger.log(
`Execute task ${cronJobName} ` +
`by schedule ${task.schedule} ` +
`with ${task.csvLinks.length} queries`,
);
this.logger.debug(`Queries - ${JSON.stringify(task.csvLinks)}`);
const csvIssuesStore = await this.loadCsv(task);
this.logger.debug(
`Loaded from csv issues count - ${Object.keys(csvIssuesStore).length}`,
);
const existsIssuesStore = await this.getCachedIssues(csvIssuesStore);
const ids = this.filterIssueIdsForUpdate(
csvIssuesStore,
existsIssuesStore,
);
this.logger.debug(
`Issues for update ${ids.length} - ${JSON.stringify(ids)}`,
);
if (ids && ids.length > 0) {
this.issues.next(ids);
}
};
}
private async loadCsv(task: Task): Promise<CsvIssuesStore> {
const res: CsvIssuesStore = {};
for (let i = 0; i < task.csvLinks.length; i++) {
const csvLink = task.csvLinks[i];
const csvData = await this.redmineDataLoader.loadCsv(csvLink);
if (!csvData || csvData.length <= 0) continue;
for (let j = 0; j < csvData.length; j++) {
const issue = csvData[j];
const issueId = Object.values(issue)[0];
issue.id = Number(issueId);
issue.updatedAt = DateTime.fromFormat(
issue[task.updatedAtFieldName],
task.dateTimeFormat,
);
res[issueId] = issue;
}
}
return res;
}
private async getCachedIssues(
store: CsvIssuesStore,
): Promise<Record<number, RedmineTypes.ExtendedIssue>> {
const ids = Object.keys(store).map((k) => Number(k));
const issues = await this.issuesService.getIssues(ids);
return issues.reduce((acc, issue) => {
acc[issue.id] = issue;
return acc;
}, {});
}
private filterIssueIdsForUpdate(
csvIssues: CsvIssuesStore,
existsIssues: Record<number, RedmineTypes.ExtendedIssue>,
): number[] {
const res: number[] = [];
for (const [issueId, issue] of Object.entries(csvIssues)) {
const id = Number(issueId);
if (!existsIssues[id]) {
res.push(id);
continue;
}
const existingIssue = existsIssues[id];
const csvIssueTimestamp = issue.updatedAt?.isValid
? issue.updatedAt.toMillis()
: null;
const existingIssueTimestamp =
this.getTimestampFromCachedIssue(existingIssue);
if (!existingIssueTimestamp) {
res.push(id);
continue;
}
if (existingIssueTimestamp < csvIssueTimestamp) {
res.push(id);
continue;
}
}
return res;
}
private getTimestampFromCachedIssue(
issue: RedmineTypes.ExtendedIssue,
): number | null {
if (
typeof issue.updated_on_timestamp === 'number' &&
issue.updated_on_timestamp > 0
) {
return issue.updated_on_timestamp;
}
let dt = DateTime.fromISO(issue.updated_on);
if (dt.isValid) {
return dt.toMillis();
}
if (
typeof issue.created_on_timestamp === 'number' &&
issue.created_on_timestamp > 0
) {
return issue.created_on_timestamp;
}
dt = DateTime.fromISO(issue.created_on);
if (dt.isValid) {
return dt.toMillis();
}
return null;
}
}

View file

@ -22,6 +22,7 @@ export class DashboardsDataService {
const cfg = await this.dashboardsService.load(id);
const results: WidgetWithData[] = [];
let isSuccess = false;
let counter = 0;
if (!cfg?.widgets || cfg?.widgets?.length <= 0) {
return results;
}
@ -35,12 +36,13 @@ export class DashboardsDataService {
cfg,
);
if (loadRes.result) {
counter++;
isSuccess = true;
loadRes.result.widgetId = widget.id;
results.push({ data: loadRes.result, widgetId: widget.id });
}
}
if (!isSuccess) throw createAppError('CANNOT_LOAD_DATA');
if (!isSuccess && counter > 0) throw createAppError('CANNOT_LOAD_DATA');
return results;
}

View file

@ -0,0 +1,49 @@
import { Inject, Injectable } from '@nestjs/common';
import { WidgetDataLoaderInterface } from '../widget-data-loader-interface';
import {
Result,
AppError,
success,
fail,
createAppError,
} from '@app/event-emitter/utils/result';
import {
CalendarService,
IssueAndEvent,
} from '@app/event-emitter/calendar/calendar.service';
import nano from 'nano';
export type DataLoaderParams = {
/** Период для выборки предстоящих событий в днях */
period: number;
/** Фильтр для выборки данных из couchdb */
filter: nano.MangoQuery;
};
@Injectable()
export class CalendarWidgetDataLoaderService
implements WidgetDataLoaderInterface<DataLoaderParams, any, IssueAndEvent[]>
{
constructor(
@Inject('CALENDAR_SERVICE') private calendarService: CalendarService,
) {}
isMyConfig(): boolean {
return true;
}
async load(
dataLoaderParams: DataLoaderParams,
): Promise<Result<IssueAndEvent[], AppError>> {
let data: IssueAndEvent[];
try {
data = await this.calendarService.getRawData(
dataLoaderParams.filter,
dataLoaderParams.period * 24 * 60 * 60 * 1000,
);
return success(data);
} catch (ex) {
return fail(createAppError(ex.message ? ex.message : 'UNKNOWN_ERROR'));
}
}
}

View file

@ -17,6 +17,7 @@ import {
AppError,
Result,
createAppError,
fail,
success,
} from '@app/event-emitter/utils/result';

View file

@ -5,6 +5,7 @@ import { ListIssuesByUsersLikeJiraWidgetDataLoaderService } from './widget-data-
import { RootIssueSubTreesWidgetDataLoaderService } from './widget-data-loader/root-issue-subtrees.widget-data-loader.service';
import { createInteractiveWidget } from './interactive-widget-factory';
import { Result, success } from '@app/event-emitter/utils/result';
import { CalendarWidgetDataLoaderService } from './widget-data-loader/calendar.widget-data-loader.service';
@Injectable()
export class WidgetsCollectionService {
@ -14,6 +15,7 @@ export class WidgetsCollectionService {
private listIssuesByFieldsWidgetDataLoaderService: ListIssuesByFieldsWidgetDataLoaderService,
private listIssuesByUsersLikeJiraWidgetDataLoaderService: ListIssuesByUsersLikeJiraWidgetDataLoaderService,
private rootIssueSubTreesWidgetDataLoaderService: RootIssueSubTreesWidgetDataLoaderService,
private calendarWidgetDataLoaderService: CalendarWidgetDataLoaderService,
) {
const collection = [
createInteractiveWidget(
@ -40,6 +42,10 @@ export class WidgetsCollectionService {
this.rootIssueSubTreesWidgetDataLoaderService,
'issues_list_by_tree',
),
createInteractiveWidget(
this.calendarWidgetDataLoaderService,
'calendar_next_events',
),
];
collection.forEach((w) => this.appendWidget(w));

View file

@ -36,6 +36,10 @@ import { ListIssuesByUsersLikeJiraWidgetDataLoaderService } from './dashboards/w
import { ListIssuesByFieldsWidgetDataLoaderService } from './dashboards/widget-data-loader/list-issues-by-fields.widget-data-loader.service';
import { WidgetsCollectionService } from './dashboards/widgets-collection.service';
import { DashboardsController } from './dashboards/dashboards.controller';
import { CalendarWidgetDataLoaderService } from './dashboards/widget-data-loader/calendar.widget-data-loader.service';
import { ScheduleModule } from '@nestjs/schedule';
import { CsvListenerFactory } from './csvlistener/csv-listener';
import { RootIssueListenerFactory } from './rootissuelistener/root-issue-listener';
@Module({})
export class EventEmitterModule implements OnModuleInit {
@ -44,6 +48,7 @@ export class EventEmitterModule implements OnModuleInit {
module: EventEmitterModule,
imports: [
ConfigModule.forRoot({ load: [() => params?.config || MainConfig()] }),
ScheduleModule.forRoot(),
],
providers: [
EventEmitterService,
@ -104,6 +109,9 @@ export class EventEmitterModule implements OnModuleInit {
DashboardsService,
DashboardsDataService,
WidgetsCollectionService,
CalendarWidgetDataLoaderService,
CsvListenerFactory,
RootIssueListenerFactory,
],
exports: [
EventEmitterService,
@ -142,6 +150,7 @@ export class EventEmitterModule implements OnModuleInit {
DashboardsService,
DashboardsDataService,
WidgetsCollectionService,
CalendarWidgetDataLoaderService,
],
controllers: [
MainController,
@ -159,15 +168,28 @@ export class EventEmitterModule implements OnModuleInit {
constructor(
private redmineEventsGateway: RedmineEventsGateway,
private redmineIssuesCacheWriterService: RedmineIssuesCacheWriterService,
private csvListenerFactory: CsvListenerFactory,
private rootIssueListenerFactory: RootIssueListenerFactory,
) {}
onModuleInit() {
const csvListener = this.csvListenerFactory.getCsvListener();
this.redmineEventsGateway.appendAndInitListener(csvListener);
const rootIssueListener =
this.rootIssueListenerFactory.getRootIssueListener();
this.redmineEventsGateway.appendAndInitListener(rootIssueListener);
const queue = this.redmineEventsGateway.getIssuesChangesQueue();
const subj = queue.queue;
subj.subscribe(async (issues: RedmineTypes.Issue[]) => {
this.logger.debug(
`Changed issues - ` +
issues.map((i) => `#${i.id} (${i.subject})`).join(', '),
issues.map(
(i) => {
return (i && i.id && i.subject)
? `#${i.id} (${i.subject})`
: '';
}
).join(', '),
);
for (let i = 0; i < issues.length; i++) {

View file

@ -134,25 +134,29 @@ export class RedmineEventsGateway {
return this.rssListener;
}
private listener: EventsListener | null | undefined;
private getMainListener(): EventsListener | null {
if (typeof this.listener !== 'undefined') {
return this.listener;
private listeners: EventsListener[];
private getMainListener(): EventsListener[] {
if (!this.listeners) {
this.listeners = [
this.getMailListener(),
this.getRssListener(),
// this.getCsvListener(),
];
this.listeners.forEach((l) => l && l.start && l.start());
}
return this.listeners;
}
const mailListener = this.getMailListener();
const rssListener = this.getRssListener();
if (mailListener) {
this.listener = mailListener;
} else if (rssListener) {
this.listener = rssListener;
} else {
this.listener = null;
appendAndInitListener(eventListener: EventsListener): void {
const listeners = this.getMainListener();
if (listeners.indexOf(eventListener) < 0) {
this.listeners.push(eventListener);
eventListener.start();
const issuesChangesQueue = this.getIssuesChangesQueue();
eventListener.issues.subscribe((issues) => {
issuesChangesQueue.add(issues);
});
}
if (this.listener) {
this.listener.start();
}
return this.listener;
}
private initWebSocketsSendData(): void {
@ -170,10 +174,12 @@ export class RedmineEventsGateway {
}
private initChangesLogging(): void {
if (this.listener) {
if (this.listeners && this.listeners.length > 0) {
this.getIssuesChangesQueue().queue.subscribe((data) => {
const issues = data.map((issue) => {
return `${issue['id']} - ${issue['subject']}`;
return (issue && issue.id && issue.subject)
? `${issue['id']} - ${issue['subject']}`
: '';
});
this.logger.debug('Changed issues: ' + JSON.stringify(issues));
});
@ -181,14 +187,18 @@ export class RedmineEventsGateway {
}
private initRedmineEventsGateway(): boolean {
const listener = this.getMainListener();
if (!listener) {
const listeners = this.getMainListener();
if (!listeners || listeners.length <= 0) {
this.logger.error('Listener not created');
return false;
}
const issuesChangesQueue = this.getIssuesChangesQueue();
listener.issues.subscribe((issues) => {
issuesChangesQueue.add(issues);
listeners.forEach((l) => {
l &&
l.issues &&
l.issues.subscribe((issues) => {
issuesChangesQueue.add(issues);
});
});
this.initWebSocketsSendData();
this.initWebHooksSendData();

View file

@ -24,7 +24,7 @@ export const UNKNOWN_CALENDAR_EVENT = 'Unknown calendar event';
@Injectable()
export class CalendarEnhancer implements IssueEnhancerInterface {
private logger = new Logger(CalendarEnhancer.name);
name = 'calendar';
constructor(
@ -33,8 +33,15 @@ export class CalendarEnhancer implements IssueEnhancerInterface {
public descriptionCalendarParams: DescriptionParserParams,
public calendarEventsKey: string,
) {
const initParams = {useForProjects, customFields, descriptionCalendarParams, calendarEventsKey};
this.logger.debug(`Calendar enhancer init with ${JSON.stringify(initParams)}`);
const initParams = {
useForProjects,
customFields,
descriptionCalendarParams,
calendarEventsKey,
};
this.logger.debug(
`Calendar enhancer init with ${JSON.stringify(initParams)}`,
);
}
async enhance(
@ -49,10 +56,18 @@ export class CalendarEnhancer implements IssueEnhancerInterface {
try {
res[this.calendarEventsKey] = this.getCalendarEvents(res);
} catch (ex) {
this.logger.error(`Error at parsing calendar events, message - ${ex}: ${(ex as Error)?.stack}`);
this.logger.error(
`Error at parsing calendar events, message - ${ex}: ${
(ex as Error)?.stack
}`,
);
return res;
}
this.logger.debug(`Calendar events for #${issue.id}: issue.${this.calendarEventsKey} = ${JSON.stringify(res[this.calendarEventsKey])}`);
this.logger.debug(
`Calendar events for #${issue.id}: issue.${
this.calendarEventsKey
} = ${JSON.stringify(res[this.calendarEventsKey])}`,
);
return res;
}
@ -147,7 +162,9 @@ export class CalendarEnhancer implements IssueEnhancerInterface {
const lines = text.split('\n').map((line) => line.trim());
const calendarStartIndex = lines.indexOf(this.descriptionCalendarParams.title);
const calendarStartIndex = lines.indexOf(
this.descriptionCalendarParams.title,
);
if (calendarStartIndex < 0) return [];
let index = calendarStartIndex + 1;

View file

@ -11,6 +11,22 @@ export class IssuesController {
return await this.issuesService.find(params);
}
@Get('find-from-root/:id')
async getIssuesFromRoot(
@Param('id') id: number,
): Promise<RedmineTypes.Issue[]> {
const rootIssue = await this.issuesService.getIssue(id);
const res = await this.issuesService.getIssuesFromRoot(rootIssue);
return res;
}
@Post('find-from-merged-trees-and-query')
async findFromMergedTreesAndQuery(
@Body() params: any,
): Promise<RedmineTypes.Issue[]> {
return await this.issuesService.mergedTreesAndFind(params);
}
@Get(':id')
async getIssue(@Param('id') id: number): Promise<RedmineTypes.Issue> {
return await this.issuesService.getIssue(id);

View file

@ -10,6 +10,7 @@ import nano from 'nano';
import { UNLIMITED } from '../consts/consts';
import { GetParentsHint } from '../utils/get-parents-hint';
import { TreeIssuesStore } from '../utils/tree-issues-store';
import { FlatIssuesStore } from '../utils/flat-issues-store';
export const ISSUE_MEMORY_CACHE_LIFETIME = 30 * 1000;
const ISSUE_MEMORY_CACHE_AUTOCLEAN_INTERVAL = 1000 * 60 * 5;
@ -18,6 +19,12 @@ export namespace IssuesServiceNs {
export type IssuesLoader = (
ids: number[],
) => Promise<Record<number, RedmineTypes.Issue | null>>;
export type TreesAndQuery = {
rootIds?: number[];
rootIssues?: RedmineTypes.Issue[];
query?: nano.MangoQuery;
};
}
@Injectable()
@ -176,4 +183,43 @@ export class IssuesService {
await treeIssuesStore.fillData(loader);
return treeIssuesStore.getIssuesWithChildren();
}
async getIssuesFromRoot(
rootIssue: RedmineTypes.Issue,
): Promise<RedmineTypes.Issue[]> {
const treeStore = new TreeIssuesStore();
treeStore.setRootIssue(rootIssue);
const loader = this.createDynamicIssuesLoader();
await treeStore.fillData(loader);
return treeStore.getFlatStore().getIssues();
}
async mergedTreesAndFind(
query: IssuesServiceNs.TreesAndQuery,
): Promise<RedmineTypes.Issue[]> {
const flatStore = new FlatIssuesStore();
const loader = this.createDynamicIssuesLoader();
if (query.query && query.query.selector) {
const issues = await this.find(query.query);
issues.forEach((issue) => flatStore.push(issue));
}
const rootIssues = [];
if (query.rootIds) {
const issues = await this.getIssues(query.rootIds);
rootIssues.push(...issues);
}
if (query.rootIssues) {
rootIssues.push(...query.rootIssues);
}
for (let i = 0; i < rootIssues.length; i++) {
const rootIssue = rootIssues[i];
const issues = await this.getIssuesFromRoot(rootIssue);
issues.forEach((issue) => flatStore.push(issue));
}
await flatStore.fillData(loader);
return flatStore.getIssues();
}
}

View file

@ -3,10 +3,13 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { RedmineTypes } from '../models/redmine-types';
import { EnhancerService } from '../issue-enhancers/enhancer.service';
import { parse as csvParse } from 'csv/sync';
@Injectable()
export class RedmineDataLoader {
urlPrefix: string;
redmineUrl: string;
redmineToken: string;
private logger = new Logger(RedmineDataLoader.name);
@ -15,6 +18,8 @@ export class RedmineDataLoader {
private enhancerService: EnhancerService,
) {
this.urlPrefix = this.configService.get<string>('redmineUrlPrefix');
this.redmineUrl = this.configService.get<string>('redmineUrlPublic');
this.redmineToken = this.configService.get<string>('redmineToken');
}
async loadIssues(issues: number[]): Promise<(RedmineTypes.Issue | null)[]> {
@ -22,7 +27,13 @@ export class RedmineDataLoader {
return Promise.all(promises);
}
async loadIssue(issueNumber: number): Promise<RedmineTypes.Issue | null> {
async loadIssue(
issueNumber: number,
skipEnhancers = false,
): Promise<RedmineTypes.Issue | null> {
if (issueNumber <= 0) {
return null;
}
const url = this.getIssueUrl(issueNumber);
let resp;
try {
@ -41,6 +52,7 @@ export class RedmineDataLoader {
this.logger.debug(
`Loaded issue, issueNumber = ${issueNumber}, subject = ${resp.data.issue.subject}`,
);
if (skipEnhancers) return resp.data.issue;
let enhancedIssue;
try {
enhancedIssue = await this.enhancerService.enhanceIssue(resp.data.issue);
@ -58,7 +70,7 @@ export class RedmineDataLoader {
}
async loadUser(userNumber: number): Promise<RedmineTypes.User | null> {
if (userNumber <= 0) {
if (typeof userNumber !== 'number' || userNumber <= 0) {
this.logger.warn(`Invalid userNumber = ${userNumber}`);
return null;
}
@ -94,4 +106,34 @@ export class RedmineDataLoader {
private getUserUrl(userNumber: number): string {
return `${this.urlPrefix}/users/${userNumber}.json`;
}
async loadCsv(
urlQuery: string,
csvParserParams?: any,
): Promise<Record<string, any>[]> {
if (!csvParserParams) {
csvParserParams = {
delimiter: ';',
quote: '"',
columns: true,
skip_empty_lines: true,
};
}
const resp = await fetch(urlQuery, {
headers: {
'X-Redmine-API-Key': this.redmineToken,
},
});
const rawData = await resp.text();
let res;
try {
res = csvParse(rawData, csvParserParams);
} catch (ex) {
this.logger.error(
`Error at loading csv from redmine, query - ${urlQuery}, ex - ${ex}`,
);
return null;
}
return res;
}
}

View file

@ -0,0 +1,149 @@
import { Injectable, Logger } from '@nestjs/common';
import { EventsListener } from '../events/events-listener';
import { BehaviorSubject } from 'rxjs';
import { SchedulerRegistry } from '@nestjs/schedule';
import { RedmineDataLoader } from '../redmine-data-loader/redmine-data-loader';
import { randomUUID } from 'crypto';
import { RedmineTypes } from '../models/redmine-types';
import { CronJob } from 'cron';
import { ConfigService } from '@nestjs/config';
export type Task = {
schedule: string;
rootIssues: number[];
};
export type RootIssueListenerParams = {
tasks: Task[];
};
@Injectable()
export class RootIssueListenerFactory {
private rootIssueListener: RootIssueListener;
constructor(
private configService: ConfigService,
private schedulerRegistry: SchedulerRegistry,
private redmineDataLoader: RedmineDataLoader,
) {}
getRootIssueListener(): RootIssueListener {
if (!this.rootIssueListener) {
const params = this.configService.get('rootIssueListener');
this.rootIssueListener = new RootIssueListener(
params,
this.schedulerRegistry,
'default_root_issue_listener',
this.redmineDataLoader,
);
}
return this.rootIssueListener;
}
}
@Injectable()
export class RootIssueListener implements EventsListener {
private logger = new Logger(RootIssueListener.name);
issues: BehaviorSubject<number[]> = new BehaviorSubject([]);
constructor(
private params: RootIssueListenerParams,
private schedulerRegistry: SchedulerRegistry,
private jobPrefix: string,
private redmineDataLoader: RedmineDataLoader,
) {
this.logger.log(
`Root issue listener created with params - ${JSON.stringify(params)}`,
);
}
start(): void {
const tasks = this?.params?.tasks || [];
this.logger.debug(`Scheduling ${tasks.length} tasks`);
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
const cronJobName = this.createCronJobName();
const cronJob = new CronJob(
task.schedule,
this.createLoader(task, cronJobName),
);
this.schedulerRegistry.addCronJob(cronJobName, cronJob);
cronJob.start();
}
}
stop(): void {
const jobs = this.schedulerRegistry.getCronJobs();
jobs.forEach((job, jobName) => {
if (this.isListenerCronJon(jobName)) {
job.stop();
this.schedulerRegistry.deleteCronJob(jobName);
}
});
}
private createCronJobName(): string {
return `${this.jobPrefix}_${randomUUID()}`;
}
private isListenerCronJon(name: string): boolean {
return name.startsWith(`${this.jobPrefix}_`);
}
private createLoader(task: Task, cronJobName: string): () => Promise<void> {
return async () => {
this.logger.log(
`Execute task ${cronJobName} ` +
`by schedule ${task.schedule} ` +
`with ${task.rootIssues.length} root issues`,
);
const issuesStore = await this.getRootIssuesFromRedmine(task);
this.logger.debug(
`Loaded root issues ` +
`${Object.keys(issuesStore).length} - ` +
`${JSON.stringify(Object.keys(issuesStore).map((i) => Number(i)))}`,
);
const ids = this.getAllRootIssueIds(Object.values(issuesStore), []);
this.logger.debug(
`Issues for update ${ids.length} - ${JSON.stringify(ids)}`,
);
if (ids && ids.length > 0) {
this.issues.next(ids);
}
};
}
private async getRootIssuesFromRedmine(
task: Task,
): Promise<Record<number, RedmineTypes.ExtendedIssue>> {
const res = {};
const SKIP_ENHANCERS = true;
for (let i = 0; i < task.rootIssues.length; i++) {
const issueId = task.rootIssues[i];
if (typeof issueId !== 'number' || issueId <= 0) continue;
const issue = await this.redmineDataLoader.loadIssue(
issueId,
SKIP_ENHANCERS,
);
if (issue) {
res[issueId] = issue;
}
}
return res;
}
private getAllRootIssueIds(
issues: RedmineTypes.ExtendedIssue[] | RedmineTypes.Children,
res: number[],
): number[] {
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
if (issue.children && issue.children.length > 0) {
res.push(issue.id);
this.getAllRootIssueIds(issue.children, res);
}
}
return res;
}
}

View file

@ -46,6 +46,32 @@ export class UsersService {
);
}
async findUserByLogin(login: string): Promise<RedmineTypes.User | null> {
const userFromMemoryCache = this.memoryCache.find((item) => {
const email = item.mail;
if (!email) return false;
return email.startsWith(login);
});
if (userFromMemoryCache) {
return RedmineTypes.CreateUser(userFromMemoryCache);
}
const usersDb = await this.users.getDatasource();
const res = await usersDb.find({
selector: {
mail: {
$regex: login,
},
},
limit: 1,
});
if (!res || !res.docs || !res.docs[0]) {
return null;
}
const userFromDb = res.docs[0];
this.memoryCache.set(userFromDb.id, userFromDb);
return RedmineTypes.CreateUser(userFromDb);
}
async findUserByName(
firstname: string,
lastname: string,

View file

@ -35,7 +35,9 @@ export function parse(str: string, params?: Moo.Rules): Moo.Token[] {
}
return res;
} catch (ex) {
logger.error(`Error at parse str=${str} with params=${params}, error message - ${ex}`);
logger.error(
`Error at parse str=${str} with params=${params}, error message - ${ex}`,
);
return [];
}
}
@ -140,7 +142,7 @@ export function parseToCalendarEvent(
to = date1.set({
hour: time2.hours,
minute: time2.minutes,
second: time2.seconds
second: time2.seconds,
});
} else {
to = from.plus(Luxon.Duration.fromMillis(DEFAULT_EVENT_DURATION));

2331
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -31,19 +31,22 @@
"@nestjs/websockets": "^8.4.4",
"axios": "^0.27.2",
"cache-manager": "^4.1.0",
"csv": "^6.3.6",
"handlebars": "^4.7.7",
"hbs": "^4.2.0",
"imap-simple": "^5.1.0",
"jsonc-parser": "^3.2.0",
"jsonpath-plus": "^8.1.0",
"luxon": "^3.1.0",
"moo": "^0.5.2",
"nano": "^10.0.0",
"node-telegram-bot-api": "^0.59.0",
"node-telegram-bot-api": "^0.66.0",
"reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2",
"rss-parser": "^3.12.0",
"rxjs": "^7.2.0",
"socket.io": "^4.4.1"
"socket.io": "^4.4.1",
"working-time-calculator": "^0.0.2"
},
"devDependencies": {
"@nestjs/cli": "^8.0.0",
@ -53,10 +56,11 @@
"@types/cron": "^2.0.0",
"@types/express": "^4.17.13",
"@types/jest": "27.4.0",
"@types/jsonpath": "^0.2.4",
"@types/luxon": "^3.1.0",
"@types/moo": "^0.5.6",
"@types/node": "^16.0.0",
"@types/node-telegram-bot-api": "^0.57.1",
"@types/node-telegram-bot-api": "^0.64.6",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^5.0.0",
"@typescript-eslint/parser": "^5.0.0",

View file

@ -53,6 +53,7 @@ import { CreateTagManagerServiceProvider } from './tags-manager/tags-manager.ser
import { CalendarEnhancer } from '@app/event-emitter/issue-enhancers/calendar-enhancer';
import { Dashboards as DashboardsDs } from '@app/event-emitter/couchdb-datasources/dashboards';
import { DashboardInitService } from './dashboards/dashboard-init.service';
import { TelegramBotController } from './telegram-bot/telegram-bot.controller';
@Module({
imports: [
@ -76,6 +77,7 @@ import { DashboardInitService } from './dashboards/dashboard-init.service';
SimpleKanbanBoardController,
SimpleIssuesListController,
TagsManagerController,
TelegramBotController,
],
providers: [
AppService,

View file

@ -9,6 +9,7 @@ import { PersonalParsedMessage } from 'src/models/personal-parsed-message.model'
@Injectable()
export class PersonalNotificationsService {
private userNameRe = /@([\wА-Яа-яЁё]+) ([\wА-Яа-яЁё]+)@/g;
private userName2Re = /@([\wА-Яа-яЁё\.]+)/g;
private logger = new Logger(PersonalNotificationsService.name);
$messages = new Subject<IssueAndPersonalParsedMessageModel>();
@ -71,6 +72,16 @@ export class PersonalNotificationsService {
}
result = results.next();
}
const results2 = notes.matchAll(this.userName2Re);
let result2 = results2.next();
while (!result2.done) {
if (result.value && result.value[1]) {
const login = result.value[1];
const user = await this.usersService.findUserByLogin(login);
if (user) recipients.push(user.id);
}
result2 = results2.next();
}
if (recipients.length > 0) {
return {
message: notes,

View file

@ -7,7 +7,7 @@ import {
CurrentIssuesEccmReportService,
} from 'src/reports/current-issues-eccm.report.service';
import { UserMetaInfoService } from 'src/user-meta-info/user-meta-info.service';
import { TelegramBotService } from '../telegram-bot.service';
import { TelegramBotService, cutMessage } from '../telegram-bot.service';
import { TelegramBotHandlerInterface } from '../telegram.bot-handler.interface';
@Injectable()
@ -45,7 +45,7 @@ export class CurrentIssuesEccmBotHandlerService
fields: CurrentIssuesEccmReport.Defaults.currentIssuesFields,
});
this.logger.debug(`Current issues eccm report: ${report}`);
bot.sendMessage(msg.chat.id, report || 'empty report', {
bot.sendMessage(msg.chat.id, cutMessage(report) || 'empty report', {
parse_mode: 'HTML',
});
});

View file

@ -0,0 +1,18 @@
import { Body, Controller, Post } from '@nestjs/common';
import { SendMessageParams, TelegramBotService } from './telegram-bot.service';
import {
BadRequestErrorHandler,
getOrAppErrorOrThrow,
} from '@app/event-emitter/utils/result';
@Controller('/api/telegram-bot')
export class TelegramBotController {
constructor(private telegramBotService: TelegramBotService) {}
@Post('send-message')
async sendMessage(@Body() params: SendMessageParams): Promise<void> {
await getOrAppErrorOrThrow(async () => {
await this.telegramBotService.sendMessageByParams(params);
}, BadRequestErrorHandler);
}
}

View file

@ -8,6 +8,73 @@ import { UserMetaInfoModel } from 'src/models/user-meta-info.model';
import { CurrentIssuesEccmBotHandlerService } from './handlers/current-issues-eccm.bot-handler.service';
import { TelegramBotHandlerInterface } from './telegram.bot-handler.interface';
import { SetDailyEccmUserCommentBotHandlerService } from './handlers/set-daily-eccm-user-comment.bot-handler.service';
import { IssuesService } from '@app/event-emitter/issues/issues.service';
import { JSONPath } from 'jsonpath-plus';
import { RedmineTypes } from '@app/event-emitter/models/redmine-types';
import { createAppError } from '@app/event-emitter/utils/result';
const MAX_TELEGRAM_MESSAGE_LENGTH = 4000;
export function cutMessage(msg: string): string {
if (msg.length > MAX_TELEGRAM_MESSAGE_LENGTH) {
return msg.slice(0, 4000) + '...';
}
return msg;
}
export type OptionsParams = {
parse_mode?: TelegramBot.ParseMode | undefined;
};
export type RecipientByName = {
firstname: string;
lastname: string;
};
export type RecipientByIssueField = {
issueId: number;
field: string;
};
export type SendMessageParams = {
recipients: (RecipientByName | RecipientByIssueField)[];
msg: string;
options?: OptionsParams;
};
export function validateSendMessageParams(params: SendMessageParams): string[] {
const res: string[] = [];
if (typeof params.msg !== 'string') {
res.push('Wrong msg field value, must be string');
}
if (
typeof params.recipients !== 'object' ||
typeof params.recipients.length !== 'number'
) {
res.push('Wrong recipients field value, must be array');
}
for (let i = 0; i < params.recipients?.length; i++) {
const r: any = params.recipients[i];
const checkRecipient = Boolean(
(typeof r.firstname === 'string' && typeof r.lastname === 'string') ||
(typeof r.issueId === 'number' && typeof r.field === 'string'),
);
if (!checkRecipient) {
res.push(
'Wrong recipient item value, must be object with firstname+lastname or issueId+field',
);
}
}
if (
params?.options?.parse_mode &&
['Markdown', 'MarkdownV2', 'HTML'].indexOf(params.options.parse_mode) < 0
) {
res.push(
`Wrong options.parse_mode value, must be one of 'Markdown', 'MarkdownV2', 'HTML'`,
);
}
return res;
}
@Injectable()
export class TelegramBotService {
@ -26,6 +93,7 @@ export class TelegramBotService {
private configService: ConfigService,
private currentIssuesBotHandlerService: CurrentIssuesEccmBotHandlerService,
private setDailyEccmUserCommentBotHandlerService: SetDailyEccmUserCommentBotHandlerService,
private issuesService: IssuesService,
) {
this.telegramBotToken = this.configService.get<string>('telegramBotToken');
this.redminePublicUrlPrefix =
@ -94,7 +162,7 @@ export class TelegramBotService {
`message = ${helpMessage}`,
);
try {
this.bot.sendMessage(msg.chat.id, helpMessage);
this.bot.sendMessage(msg.chat.id, cutMessage(helpMessage));
} catch (ex) {
this.logger.error(`Error at send help message - ${ex?.message}`);
}
@ -111,7 +179,18 @@ export class TelegramBotService {
);
if (!userMetaInfo) return false;
const chatId = userMetaInfo.telegram_chat_id;
await this.bot.sendMessage(chatId, msg, options);
const formattedMsg = cutMessage(msg);
try {
await this.bot.sendMessage(chatId, formattedMsg, options);
} catch (ex) {
this.logger.error(
`Error at send message to telegram: ` +
`redmineId - ${redmineId}; ` +
`message - ${formattedMsg}; ` +
`exception - ${ex?.message || '<null message>'}`
);
return false;
}
this.logger.debug(
`Sent message for redmineUserId = ${redmineId}, ` +
`telegramChatId = ${chatId}, ` +
@ -133,6 +212,62 @@ export class TelegramBotService {
return await this.sendMessageByRedmineId(user.id, msg, options);
}
async sendMessageByParams(params: SendMessageParams): Promise<void> {
const paramsErrors = validateSendMessageParams(params);
if (paramsErrors && paramsErrors.length > 0) {
throw createAppError(`Params errors - ${JSON.stringify(paramsErrors)}`);
}
const issuesStore = await this.getIssuesStore(params);
for (let i = 0; i < params.recipients.length; i++) {
const recipient: any = params.recipients[i];
if (recipient.firstname && recipient.lastname) {
await this.sendMessageByName(
recipient.firstname,
recipient.lastname,
params.msg,
params.options,
);
} else if (recipient.issueId && recipient.field) {
const issue = issuesStore[recipient.issueId];
if (!issue) continue;
let fieldValue: any;
try {
fieldValue = JSONPath({ json: issue, path: recipient.field });
} catch (ex) {
const warnMsg =
`Error at get value from issueId = ${recipient.issueId}; ` +
`field = ${JSON.stringify(recipient.field)} ` +
`- ${ex.message}`;
this.logger.warn(warnMsg);
continue;
}
if (fieldValue && fieldValue.length > 0) {
fieldValue = fieldValue[0];
} else {
continue;
}
if (
typeof fieldValue === 'object' &&
fieldValue.firstname &&
fieldValue.lastname
) {
await this.sendMessageByName(
fieldValue.firstname,
fieldValue.lastname,
params.msg,
params.options,
);
} else if (typeof fieldValue === 'number') {
await this.sendMessageByRedmineId(
fieldValue,
params.msg,
params.options,
);
}
}
}
}
private async register(
msg: TelegramBot.Message,
): Promise<{ result: boolean; message: string }> {
@ -144,7 +279,15 @@ export class TelegramBotService {
`with message ${message}, ` +
`log data = ${JSON.stringify(logData || null)}`;
this.logger.log(logMsg);
this.bot.sendMessage(msg.chat.id, message);
try {
this.bot.sendMessage(msg.chat.id, message);
} catch (ex) {
this.logger.error(
`Error at send message to telegram: ` +
`message - ${message}; ` +
`exception - ${ex?.message || '<null message>'}`
);
}
return { result: result, message: message };
};
@ -193,4 +336,24 @@ export class TelegramBotService {
await this.userMetaInfoService.delete(userMetaInfo);
}
}
private async getIssuesStore(
params: SendMessageParams,
): Promise<Record<number, RedmineTypes.Issue>> {
const issueIds: number[] = [];
for (let i = 0; i < params.recipients.length; i++) {
const recipient: any = params.recipients[i];
if (typeof recipient.issueId === 'number' && recipient.issueId > 0) {
issueIds.push(recipient.issueId);
}
}
if (issueIds.length <= 0) {
return [];
}
const issues = await this.issuesService.getIssues(issueIds);
return issues.reduce((acc, issue) => {
acc[issue.id] = issue;
return acc;
}, {});
}
}

240
tools/sync-issues.mjs Normal file
View file

@ -0,0 +1,240 @@
import { parse } from 'jsonc-parser';
import { readFileSync } from 'fs';
import { parseArgs } from 'util';
import { parse as csvParse } from 'csv/sync';
import { DateTime } from 'luxon';
import { createInterface } from 'readline/promises';
const helpMsg = `
Синхронизация задач из Redmine с Redmine Issue Event Emitter
* --config=sync-issues.jsonc - конфигурационный файл для постановки задачи на выполнение синхронизации
* --test - тестовый прогон без запуска задачи на синхронизацию
* --yes - автоматически отвечать 'y'
Пример конфигурационного файла:
{
"redmineUrl": "http://redmine.my-company.local",
"redmineToken": "... ... ...",
"eventEmitterUrl": "http://localhost:3000"
"forceUpdate": true, // true - принудительное обновление,
// false - сравнение даты обновления в redmine и в event-emitter-е
// и обновление только просроченных задач
"updatedAtFieldName": "Обновлено",
"dateTimeFormat": "dd.MM.yyyy HH:mm",
"csvLinks": [
"http://redmine.my-company.local/projects/test-project/issues.csv?query_id=2011", // закрытые за сутки
"http://redmine.my-company.local/projects/test-project/issues.csv?query_id=2012" // обновленые за сутки
],
"csvFiles": [
"/tmp/file1.csv",
"/tmp/file2.csv"
],
}
`;
var args = parseArgs({
options: {
config: {
type: 'string',
},
test: {
type: 'boolean',
},
yes: {
type: 'boolean',
},
},
});
if (!args?.values?.config) {
console.log(helpMsg);
process.exit(0);
}
function readConfig(fileName) {
const rawData = readFileSync(fileName, { encoding: 'utf8' });
return parse(rawData);
}
var config = readConfig(args.values.config);
console.log(`Конфигурационный файл:\n${JSON.stringify(config, null, ' ')}\n`); // DEBUG
const UNLIMIT = 999999;
async function getIssuesByQuery(selector) {
const resp = await fetch(`${config.eventEmitterUrl}/issues/find`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
selector: selector,
limit: UNLIMIT,
}),
});
return await resp.json();
}
async function getIssuesByList(issueIds) {
return await getIssuesByQuery({
id: {
$in: issueIds,
},
});
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function getCsvFromRedmine(url, token) {
const resp = await fetch(url, {
headers: {
'X-Redmine-API-Key': token,
},
});
const rawData = await resp.text();
return csvParse(rawData, {
delimiter: ';',
quote: '"',
columns: true,
skip_empty_lines: true,
cast: (value, context) => {
if (context.column == '#') {
return Number(value);
}
return value;
},
});
}
async function getCsvFromLocalFile(fileName) {
const rawData = readFileSync(fileName, { encoding: 'utf8' });
const data = csvParse(rawData, {
delimiter: ';',
quote: '"',
columns: true,
skip_empty_lines: true,
cast: (value, context) => {
if (context.column == '#') {
return Number(value);
}
return value;
},
});
return data;
}
async function loadAllCsv() {
const res = {};
const csvLinks = config?.csvLinks || [];
const csvFiles = config?.csvFiles || [];
const token = config?.redmineToken || '';
function appendIssuesToRes(issues) {
issues.forEach((issue) => {
const issueId = Object.values(issue)[0];
issue.id = Number(issueId);
const updatedAt = DateTime.fromFormat(
issue[config.updatedAtFieldName],
config.dateTimeFormat,
);
if (!res[issueId]) {
res[issueId] = issue;
} else {
const existsIssue = res[issueId];
const existsUpdatedAt = DateTime.fromFormat(
existsIssue[config.updatedAtFieldName],
config.dateTimeFormat,
);
if (updatedAt.diff(existsUpdatedAt).as('seconds') > 0) {
res[issueId] = issue;
}
}
});
}
for (let i = 0; i < csvLinks.length; i++) {
const link = csvLinks[i];
const issues = await getCsvFromRedmine(link, token);
appendIssuesToRes(issues);
}
for (let i = 0; i < csvFiles.length; i++) {
const file = csvFiles[i];
const issues = await getCsvFromLocalFile(file);
appendIssuesToRes(issues);
}
return Object.values(res);
}
async function loadFromEventEmitter(issueIds) {
return await getIssuesByList(issueIds);
}
async function getIssueIdsForUpdate(newIssues) {
if (config.forceUpdate) {
return newIssues.map((i) => i.id);
}
const existsIssue = await loadFromEventEmitter(newIssues.map((i) => i.id));
const existsIssueMap = existsIssue.reduce((acc, issue) => {
acc[issue.id] = issue;
return acc;
}, {});
const newIds = newIssues
.filter((newIssue) => {
const existsIssue = existsIssueMap[newIssue.id];
if (!existsIssue) return true;
const newUpdatedAt = DateTime.fromFormat(
newIssue[config.updatedAtFieldName],
config.dateTimeFormat,
);
const existsUpdatedTimestamp = DateTime.fromMillis(
existsIssue.timestamp__,
);
if (newUpdatedAt.diff(existsUpdatedTimestamp).as('seconds') > 0) {
return true;
}
return false;
})
.map((i) => i.id);
return newIds;
}
const issues = await loadAllCsv();
const idsForUpdate = await getIssueIdsForUpdate(issues);
console.log(`Из csv получено задач - ${issues.length} шт.`);
console.log(`Нужно обновить задач - ${idsForUpdate.length} шт.`);
console.log(`Задачи для обновления - ` + JSON.stringify(idsForUpdate));
async function continueQuestion() {
if (args.values.yes) {
return true;
}
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await readline.question(`\nПродолжить? (y/n)\n> `);
const res = answer == 'y';
readline.close();
console.log(res ? `\nПродолжаем...` : '\nНу и мне тогда это не надо');
return res;
}
if (!args.values.test) {
const answer = await continueQuestion();
if (answer) {
console.log(`Отправка задачи на синхронизацию...`);
await fetch(
`${config.eventEmitterUrl}/redmine-event-emitter/append-issues`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(idsForUpdate),
},
);
console.log(`Синхронизация запущена`);
}
}

135
tools/sync-root-issues.mjs Normal file
View file

@ -0,0 +1,135 @@
import { parse } from 'jsonc-parser';
import { readFileSync } from 'fs';
import { parseArgs } from 'util';
import { createInterface } from 'readline/promises';
const helpMsg = `
Синхронизация суммирующих задач
* --config=sync-root-issues.jsonc - конфигурационный файл для постановки задачи на выполнение синхронизации
* --yes - автоматически отвечать 'y'
Пример конфигурационного файла:
{
"redmineUrl": "http://redmine.my-company.local",
"redmineToken": "... ... ...",
"eventEmitterUrl": "http://localhost:3000",
"rootIds": [
123001,
130012
]
}
`;
var args = parseArgs({
options: {
config: {
type: 'string',
},
yes: {
type: 'boolean',
},
},
});
if (!args?.values?.config) {
console.log(helpMsg);
process.exit(0);
}
function readConfig(fileName) {
const rawData = readFileSync(fileName, { encoding: 'utf8' });
return parse(rawData);
}
var config = readConfig(args.values.config);
function getAllRootIssueIds(issues, res) {
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
if (issue.children && issue.children.length > 0) {
res.push(issue.id);
getAllRootIssueIds(issue.children, res);
}
}
return res;
}
async function loadIssueFromRedmine(issueId) {
const token = config?.redmineToken || '';
const redmineUrl = config?.redmineUrl || '';
const url = `${redmineUrl}/issues/${issueId}.json?include=children`;
const resp = await fetch(url, {
headers: {
'X-Redmine-API-Key': token,
},
});
const data = await resp.json();
return data?.issue || null;
}
async function loadAllRootIssueIds() {
const rootIssueIds = config?.rootIds || [];
const rootIssues = [];
for (let i = 0; i < rootIssueIds.length; i++) {
const rootIssueId = rootIssueIds[i];
const issue = await loadIssueFromRedmine(rootIssueId);
if (issue) {
rootIssues.push(issue);
}
}
const allRootIssueIds = getAllRootIssueIds(rootIssues, []);
return allRootIssueIds;
}
async function questionYesOrNo(question, yesMsg, noMsg) {
const readline = createInterface({
input: process.stdin,
output: process.stdout,
});
const answer = await readline.question(`\n${question} (y/n)\n> `);
const res = answer == 'y';
readline.close();
console.log(res ? `\n${yesMsg}` : `\n${noMsg}`);
return res;
}
async function main() {
console.log(
`Конфигурационный файл:\n${JSON.stringify(config, null, ' ')}\n`,
);
if (!args.values.yes) {
const res = await questionYesOrNo(
'Продолжаем?',
'Продолжаем...',
'Прервано',
);
if (!res) process.exit();
}
const rootIds = await loadAllRootIssueIds();
console.log(`Задачи для синхронизации - ${JSON.stringify(rootIds)}`);
if (!args.values.yes) {
const res = await questionYesOrNo(
'Продолжаем?',
'Продолжаем...',
'Прервано',
);
if (!res) process.exit();
}
console.log(`Отправка задач на синхронизацию...`);
await await fetch(
`${config.eventEmitterUrl}/redmine-event-emitter/append-issues`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(rootIds),
},
);
console.log(`Синхронизация запущена`);
}
await main();