This commit is contained in:
John Supplee 2021-05-31 13:59:56 -04:00
commit 80c4908fcc
39 changed files with 18903 additions and 18501 deletions

View file

@ -1,5 +1,8 @@
## Issue
If you can not login for any reason:
- https://github.com/wekan/wekan/wiki/Forgot-Password
Email settings:
- https://github.com/wekan/wekan/wiki/Troubleshooting-Mail

View file

@ -55,8 +55,8 @@ horka:swipebox@1.0.2
hot-code-push@1.0.4
html-tools@1.1.1
htmljs@1.1.0
http@1.4.3
id-map@1.1.0
http@1.4.4
id-map@1.1.1
idmontie:migrations@1.0.3
inter-process-messaging@0.1.1
jquery@1.11.11
@ -95,7 +95,7 @@ momentjs:moment@2.29.1
mongo@1.11.1
mongo-decimal@0.1.2
mongo-dev-server@1.1.0
mongo-id@1.0.7
mongo-id@1.0.8
mongo-livedata@1.0.12
mousetrap:mousetrap@1.4.6_1
mquandalle:autofocus@1.0.0
@ -174,7 +174,7 @@ rajit:bootstrap3-datepicker-zh-cn@1.7.1
rajit:bootstrap3-datepicker-zh-tw@1.7.1
random@1.2.0
rate-limit@1.0.9
react-fast-refresh@0.1.0
react-fast-refresh@0.1.1
reactive-dict@1.3.0
reactive-var@1.0.11
reload@1.3.1
@ -191,7 +191,7 @@ simple:json-routes@2.1.0
simple:rest-accounts-password@1.1.2
simple:rest-bearer-token-parser@1.0.1
simple:rest-json-error-handler@1.0.1
socket-stream-client@0.3.2
socket-stream-client@0.3.3
softwarerero:accounts-t9n@1.3.11
spacebars@1.1.0
spacebars-compiler@1.2.1

View file

@ -1,3 +1,41 @@
[Mac ChangeLog](https://github.com/wekan/wekan/wiki/Mac)
# v5.29 2021-05-29 Wekan release
This release adds the following new features:
- [Excel parent card name export](https://github.com/wekan/wekan/pull/3799).
Thanks to marcungeschikts and Enishowk.
and adds the following updates:
- Updated dependencies
[Part 1](https://github.com/wekan/wekan/commit/62150ce6c406359fba068552b4526c60faf392bb),
[Part 2](https://github.com/wekan/wekan/commit/1d9346513e4f378379b9f5192e8dad5535287f8a),
[Part 3](https://github.com/wekan/wekan/commit/6be1a330936c89fcf478efe98dd15244a98d266d).
Thanks to developers of dependencies.
- Added updated `Forgot Password` page to GitHub issue template
[Part 1](https://github.com/wekan/wekan/commit/6d0578fd5ad5f13f5ff9a285577e35fd62bba95f),
[Part 2](https://github.com/wekan/wekan/commit/ea64b17b82cd52320c0495e16385f11031dfbe3a).
Thanks to xet7.
and fixes the following bugs:
- [Try to fix Snap: Removed linting packages](https://github.com/wekan/wekan/commit/8911fe5c8de941808585a7d3462305d5b3d2763d).
Thanks to xet7.
- [Removed not working GitHub workflow](https://github.com/wekan/wekan/commit/5dd6466c0aa7479015c72519f36c2485b16e3341).
Thanks to xet7.
- [Fix typos](https://github.com/wekan/wekan/pull/3813).
Thanks to spasche.
- [Fix: Impersonate user can now export Excel/CSV/TSV/JSON.
Impersonate user and export Excel/CSV/TSV/JSON is now logged into database table
impersonatedUsers](https://github.com/wekan/wekan/commit/3908cd5413b775d1ee549f0a95304cf9998d3855).
Thanks to xet7.
- [Fixed Importing JSON exports fails](https://github.com/wekan/wekan/commit/bd1de94312e428e56d6cf5f343098475573cba0b).
Thanks to KeptnArgo and xet7.
Thanks to above GitHub users for their contributions and translators for their translations.
# v5.28 2021-05-07 Wekan release
This release adds the following new features:

View file

@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v5.28.0"
appVersion: "v5.29.0"
files:
userUploads:
- README.md

View file

@ -68,7 +68,7 @@ version: '2'
# docker exec -it wekan-db bash
# # 3) and data directory
# cd /data
# # 4) Remove previos dump
# # 4) Remove previous dump
# rm -rf dump
# # 5) Exit db container
# exit
@ -281,7 +281,7 @@ services:
#- NOTIFY_DUE_AT_HOUR_OF_DAY=8
#-----------------------------------------------------------------
# ==== EMAIL NOTIFICATION TIMEOUT, ms =====
# Defaut: 30000 ms = 30s
# Default: 30000 ms = 30s
#- EMAIL_NOTIFICATION_TIMEOUT=30000
#-----------------------------------------------------------------
# ==== CORS =====
@ -348,7 +348,7 @@ services:
#- OAUTH2_USERNAME_MAP=email
# The claim name you want to map to the full name field:
#- OAUTH2_FULLNAME_MAP=name
# Tthe claim name you want to map to the email field:
# The claim name you want to map to the email field:
#- OAUTH2_EMAIL_MAP=email
#-----------------------------------------------------------------
# ==== OAUTH2 Nextcloud ====
@ -374,7 +374,7 @@ services:
#- OAUTH2_USERNAME_MAP=id
# The claim name you want to map to the full name field:
#- OAUTH2_FULLNAME_MAP=display-name
# Tthe claim name you want to map to the email field:
# The claim name you want to map to the email field:
#- OAUTH2_EMAIL_MAP=email
#-----------------------------------------------------------------
# ==== OAUTH2 KEYCLOAK ====
@ -526,7 +526,7 @@ services:
# The attribute inside a group object listing its members. Example: member
#- LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE=
#
# The format of the value of LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE. Example: 'dn' if the users dn ist saved as value into the attribute.
# The format of the value of LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE. Example: 'dn' if the users dn is saved as value into the attribute.
#- LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT=
#
# The group name (id) that matches all users.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,51 +1,51 @@
{
"accept": "پذیرش",
"act-activity-notify": "Activity Notification",
"act-addAttachment": "added attachment __attachment__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-deleteAttachment": "deleted attachment __attachment__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-addSubtask": "added subtask __subtask__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-addLabel": "Added label __label__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-addedLabel": "Added label __label__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-removeLabel": "Removed label __label__ from card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-removedLabel": "Removed label __label__ from card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-addChecklist": "added checklist __checklist__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-addChecklistItem": "added checklist item __checklistItem__ to checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-removeChecklist": "removed checklist __checklist__ from card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-removeChecklistItem": "removed checklist item __checklistItem__ from checklist __checkList__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-checkedItem": "checked __checklistItem__ of checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-uncheckedItem": "unchecked __checklistItem__ of checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-completeChecklist": "completed checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-uncompleteChecklist": "uncompleted checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-addComment": "commented on card __card__: __comment__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-editComment": "edited comment on card __card__: __comment__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-deleteComment": "deleted comment on card __card__: __comment__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-createBoard": "created board __board__",
"act-createSwimlane": "created swimlane __swimlane__ to board __board__",
"act-createCard": "created card __card__ to list __list__ at swimlane __swimlane__ at board __board__",
"act-createCustomField": "created custom field __customField__ at board __board__",
"act-deleteCustomField": "deleted custom field __customField__ at board __board__",
"act-setCustomField": "edited custom field __customField__: __customFieldValue__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-createList": "added list __list__ to board __board__",
"act-addBoardMember": "added member __member__ to board __board__",
"act-archivedBoard": "Board __board__ moved to Archive",
"act-archivedCard": "Card __card__ at list __list__ at swimlane __swimlane__ at board __board__ moved to Archive",
"act-archivedList": "List __list__ at swimlane __swimlane__ at board __board__ moved to Archive",
"act-archivedSwimlane": "Swimlane __swimlane__ at board __board__ moved to Archive",
"act-importBoard": "imported board __board__",
"act-importCard": "imported card __card__ to list __list__ at swimlane __swimlane__ at board __board__",
"act-importList": "imported list __list__ to swimlane __swimlane__ at board __board__",
"act-joinMember": "added member __member__ to card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"act-moveCard": "moved card __card__ at board __board__ from list __oldList__ at swimlane __oldSwimlane__ to list __list__ at swimlane __swimlane__",
"act-moveCardToOtherBoard": "moved card __card__ from list __oldList__ at swimlane __oldSwimlane__ at board __oldBoard__ to list __list__ at swimlane __swimlane__ at board __board__",
"act-removeBoardMember": "removed member __member__ from board __board__",
"act-restoredCard": "restored card __card__ to list __list__ at swimlane __swimlane__ at board __board__",
"act-unjoinMember": "removed member __member__ from card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"accept": "پذیرفتن",
"act-activity-notify": "اعلان فعالیت",
"act-addAttachment": "افزودن پیوست __attachment__ به کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-deleteAttachment": "پاک کردن پیوست __attachment__ از کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-addSubtask": "فزودن کار فرعی __subtask__ به کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-addLabel": "افزودن برچسب __label__ به کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-addedLabel": "برچسب اضافه شده __label__ به کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-removeLabel": "برداشتن برچسب __label__ از کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-removedLabel": "برچسب برداشته شده __label__ از کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-addChecklist": "افزودن چک لیست __checklist__ به کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-addChecklistItem": "آیتم اضافه شده به چک لیست __checklistItem__ در چک لیست __checklist__ در کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-removeChecklist": "حذف چک لیست __checklist__ از کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-removeChecklistItem": "آیتم حذف شده از چک لیست __checklistItem__ در چک لیست __checkList__ در کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-checkedItem": "بررسی شده __checklistItem__ از چک لیست __checklist__ در کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-uncheckedItem": "بررسی نشده __checklistItem__ از چک لیست __checklist__ در کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-completeChecklist": "چک لیست کامل شده __checklist__ در کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-uncompleteChecklist": "چک لیست ناتمام __checklist__ در کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-addComment": "افزودن نظر روی کارت __card__: __comment__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-editComment": "اصلاح نظر روی کارت __card__: __comment__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-deleteComment": "حذف نظر از کارت __card__: __comment__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-createBoard": "برد ساخته شده __board__",
"act-createSwimlane": "created swimlane __swimlane__ در برد __board__",
"act-createCard": "ایجاد کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-createCustomField": "ساخت فیلد اختصاصی __customField__ در برد __board__",
"act-deleteCustomField": "حذف فیلد اختصاصی __customField__ در برد __board__",
"act-setCustomField": "اصلاح فیلد اختصاصی __customField__: __customFieldValue__ در کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-createList": "ایجاد لیست __list__ در برد __board__",
"act-addBoardMember": "افزودن عضو __member__ به برد __board__",
"act-archivedBoard": "برد __board__ به آرشیو منتقل شد",
"act-archivedCard": "کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__ به آرشیو منتقل شد",
"act-archivedList": "لیست __list__ at swimlane __swimlane__ در برد __board__ به آرشیو منتقل شد",
"act-archivedSwimlane": "Swimlane __swimlane__ در برد __board__ به آرشیو منتقل شد",
"act-importBoard": "وارد کردن برد __board__",
"act-importCard": "وارد کردن کارت __card__ به لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-importList": "وارد کردن لیست __list__ to swimlane __swimlane__ در برد __board__",
"act-joinMember": "عضو کردن __member__ به کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-moveCard": "انتقال کارت __card__ در برد __board__ از لیست __oldList__ at swimlane __oldSwimlane__ به لیست __list__ at swimlane __swimlane__",
"act-moveCardToOtherBoard": "انتقال کارت به برد دیگر __card__ از لیست __oldList__ at swimlane __oldSwimlane__ در برد __oldBoard__ به لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-removeBoardMember": "حذف عضویت __member__ از برد __board__",
"act-restoredCard": "کارت بازگردانی شده __card__ به لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-unjoinMember": "حذف عضو __member__ از کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"act-withBoardTitle": "__board__",
"act-withCardTitle": "[__board__] __card__",
"actions": "Actions",
"activities": "Activities",
"activity": "Activity",
"activity-added": "added %s to %s",
"actions": "اقدامات",
"activities": "فعالیت ها",
"activity": "فعالیت",
"activity-added": "افزودن %s به %s",
"activity-archived": "%s moved to Archive",
"activity-attached": "attached %s to %s",
"activity-created": "created %s",
@ -71,7 +71,7 @@
"add": "Add",
"activity-checked-item-card": "checked %s in checklist %s",
"activity-unchecked-item-card": "unchecked %s in checklist %s",
"activity-checklist-completed-card": "completed checklist __checklist__ at card __card__ at list __list__ at swimlane __swimlane__ at board __board__",
"activity-checklist-completed-card": "completed checklist __checklist__ در کارت __card__ در لیست __list__ at swimlane __swimlane__ در برد __board__",
"activity-checklist-uncompleted-card": "uncompleted the checklist %s",
"activity-editComment": "edited comment %s",
"activity-deleteComment": "deleted comment %s",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -344,12 +344,12 @@
"list-label-short-sort": "(M)",
"filter": "Bộ lọc",
"filter-cards": "Lọc thẻ hoặc danh sách",
"filter-dates-label": "Filter by date",
"filter-no-due-date": "No due date",
"filter-overdue": "Overdue",
"filter-due-today": "Due today",
"filter-due-this-week": "Due this week",
"filter-due-tomorrow": "Due tomorrow",
"filter-dates-label": "Lọc theo ngày",
"filter-no-due-date": "Không có ngày đến hạn",
"filter-overdue": "Quá hạn",
"filter-due-today": "Đến hạn hôm nay",
"filter-due-this-week": "Đến hạn trong tuần này",
"filter-due-tomorrow": "Đến hạn vào ngày mai",
"list-filter-label": "Lọc danh sách theo tiêu đề",
"filter-clear": "Xóa bộ lọc",
"filter-labels-label": "Lọc theo nhãn",
@ -644,7 +644,7 @@
"default": "Mặc định",
"queue": "Hàng đợi",
"subtask-settings": "Cài đặt Nhiệm vụ phụ",
"card-settings": "Cài đặt Card",
"card-settings": "Cài đặt Thẻ",
"boardSubtaskSettingsPopup-title": "Cài đặt Bảng Nhiệm vụ phụ",
"boardCardSettingsPopup-title": "Cài đặt thẻ",
"deposit-subtasks-board": "Gửi các nhiệm vụ phụ vào bảng này:",
@ -906,7 +906,7 @@
"operator-member-abbrev": "m",
"operator-assignee": "người được giao",
"operator-assignee-abbrev": "a",
"operator-creator": "creator",
"operator-creator": "người tạo",
"operator-status": "trạng thái",
"operator-due": "đến hạn",
"operator-created": "đã tạo",
@ -953,12 +953,12 @@
"globalSearch-instructions-operator-swimlane": "`__operator_swimlane__:<title>` - thẻ trong làn ngang phù hợp với *<title>*",
"globalSearch-instructions-operator-comment": "`__operator_comment__:<text>` - thẻ có nhận xét chứa *<text>*.",
"globalSearch-instructions-operator-label": "`__operator_label__:<color>` `__operator_label__:<name>` - thẻ có nhãn phù hợp *<color>* hoặc *<name>",
"globalSearch-instructions-operator-hash": "`__operator_label_abbrev__<name|color>` - shorthand for `__operator_label__:<color>` or `__operator_label__:<name>`",
"globalSearch-instructions-operator-hash": "`__operator_label_abbrev__<name|color>` - viết tắt cho `__operator_label__:<color>` hoặc `__operator_label__:<name>`",
"globalSearch-instructions-operator-user": "`__operator_user__:<username>` - thẻ trong đó *<username>* là *thành viên* hoặc *người được giao*",
"globalSearch-instructions-operator-at": "`__operator_user_abbrev__username` - viết tắt cho `người dùng:<username>`",
"globalSearch-instructions-operator-member": "`__operator_member__:<username>` - thẻ trong đó *<username>* là *thành viên*",
"globalSearch-instructions-operator-assignee": "`__operator_assignee__:<username>` - thẻ trong đó *<username>* là *người được giao*",
"globalSearch-instructions-operator-creator": "`__operator_creator__:<username>` - cards where *<username>* is the card's creator",
"globalSearch-instructions-operator-creator": "`__operator_creator__:<username>` - thẻ trong đó *<username>* là người tạo thẻ",
"globalSearch-instructions-operator-due": "`__operator_due__:<n>` - thẻ có thời hạn lên đến *<n>* ngày kể từ bây giờ. `__operator_due__:__predicate_overdue__ liệt kê tất cả các thẻ đã quá hạn sử dụng.",
"globalSearch-instructions-operator-created": "`__operator_created__:<n>` - thẻ đã được tạo *<n>* ngày trước hoặc ít hơn",
"globalSearch-instructions-operator-modified": "`__operator_modified__:<n>` - thẻ đã được sửa đổi *<n>* ngày trước hoặc ít hơn",
@ -986,25 +986,25 @@
"sort-cards": "Sắp xếp thẻ",
"cardsSortPopup-title": "Sắp xếp thẻ",
"due-date": "Ngày đến hạn",
"server-error": "Server Error",
"server-error-troubleshooting": "Please submit the error generated by the server.\nFor a snap installation, run: `sudo snap logs wekan.wekan`\nFor a Docker installation, run: `sudo docker logs wekan-app`",
"server-error": "Lỗi máy chủ",
"server-error-troubleshooting": "Vui lòng gửi lỗi do máy chủ tạo ra.\nĐể cài đặt nhanh, hãy chạy: `sudo snap logs wekan.wekan`\nĐể cài đặt Docker, hãy chạy: `sudo docker logs wekan-app`",
"title-alphabetically": "Tiêu đề (theo thứ tự bảng chữ cái)",
"created-at-newest-first": "Được tạo lúc (Mới nhất đầu tiên)",
"created-at-oldest-first": "Được tạo lúc (Cũ nhất trước)",
"links-heading": "Links",
"hide-system-messages-of-all-users": "Hide system messages of all users",
"now-system-messages-of-all-users-are-hidden": "Now system messages of all users are hidden",
"move-swimlane": "Move Swimlane",
"moveSwimlanePopup-title": "Move Swimlane",
"links-heading": "Liên kết",
"hide-system-messages-of-all-users": "Ẩn thông báo hệ thống của tất cả người dùng",
"now-system-messages-of-all-users-are-hidden": "Bây giờ thông báo hệ thống của tất cả người dùng bị ẩn",
"move-swimlane": "Di chuyển Làn ngang",
"moveSwimlanePopup-title": "Di chuyển Làn ngang",
"custom-field-stringtemplate": "String Template",
"custom-field-stringtemplate-format": "Format (use %{value} as placeholder)",
"custom-field-stringtemplate-separator": "Separator (use &#32; or &nbsp; for a space)",
"custom-field-stringtemplate-item-placeholder": "Press enter to add more items",
"creator": "Creator",
"filesReportTitle": "Files Report",
"orphanedFilesReportTitle": "Orphaned Files Report",
"reports": "Reports",
"rulesReportTitle": "Rules Report",
"copy-swimlane": "Copy Swimlane",
"copySwimlanePopup-title": "Copy Swimlane"
"custom-field-stringtemplate-format": "Định dạng (sử dụng %{value} làm trình giữ chỗ)",
"custom-field-stringtemplate-separator": "Dấu phân cách (sử dụng &#32; hoặc &nbsp; cho một khoảng trắng)",
"custom-field-stringtemplate-item-placeholder": "Nhấn enter để thêm các mục khác",
"creator": "Người tạo",
"filesReportTitle": "Tệp báo cáo",
"orphanedFilesReportTitle": "Tệp báo cáo mồ côi",
"reports": "Báo cáo",
"rulesReportTitle": "Quy tắc Báo cáo",
"copy-swimlane": "Sao chép Làn ngang",
"copySwimlanePopup-title": "Sao chép Làn ngang"
}

File diff suppressed because it is too large Load diff

View file

@ -344,12 +344,12 @@
"list-label-short-sort": "(M)",
"filter": "篩選",
"filter-cards": "篩選卡片或清單",
"filter-dates-label": "Filter by date",
"filter-no-due-date": "No due date",
"filter-overdue": "Overdue",
"filter-due-today": "Due today",
"filter-due-this-week": "Due this week",
"filter-due-tomorrow": "Due tomorrow",
"filter-dates-label": "篩選: 日期",
"filter-no-due-date": "沒有到期日",
"filter-overdue": "逾期",
"filter-due-today": "今天到期",
"filter-due-this-week": "本週到期",
"filter-due-tomorrow": "明天到期",
"list-filter-label": "篩選清單依據標題",
"filter-clear": "清除篩選條件",
"filter-labels-label": "篩選: 標籤",
@ -906,7 +906,7 @@
"operator-member-abbrev": "m",
"operator-assignee": "代理人",
"operator-assignee-abbrev": "a",
"operator-creator": "creator",
"operator-creator": "建立者",
"operator-status": "狀態",
"operator-due": "至",
"operator-created": "已建立",
@ -914,9 +914,9 @@
"operator-sort": "排序",
"operator-comment": "評論",
"operator-has": "擁有",
"operator-limit": "limit",
"operator-limit": "限制",
"predicate-archived": "已封存",
"predicate-open": "open",
"predicate-open": "開啟",
"predicate-ended": "已結束",
"predicate-all": "全部",
"predicate-overdue": "逾期",
@ -986,7 +986,7 @@
"sort-cards": "排序卡片",
"cardsSortPopup-title": "排序卡片",
"due-date": "到期日",
"server-error": "Server Error",
"server-error": "伺服器錯誤",
"server-error-troubleshooting": "Please submit the error generated by the server.\nFor a snap installation, run: `sudo snap logs wekan.wekan`\nFor a Docker installation, run: `sudo docker logs wekan-app`",
"title-alphabetically": "標題 (按字母順序)",
"created-at-newest-first": "創建於(最新優先)",

View file

@ -271,6 +271,7 @@ Cards.attachSchema(
type: Number,
decimal: true,
defaultValue: 0,
optional: true,
},
subtaskSort: {
/**

View file

@ -22,21 +22,35 @@ if (Meteor.isServer) {
* @param {string} boardId the ID of the board we are exporting
* @param {string} authToken the loginToken
*/
JsonRoutes.add('get', '/api/boards/:boardId/export', function(req, res) {
JsonRoutes.add('get', '/api/boards/:boardId/export', function (req, res) {
const boardId = req.params.boardId;
let user = null;
let impersonateDone = false;
let adminId = null;
const loginToken = req.query.authToken;
if (loginToken) {
const hashToken = Accounts._hashLoginToken(loginToken);
user = Meteor.users.findOne({
'services.resume.loginTokens.hashedToken': hashToken,
});
adminId = user._id.toString();
impersonateDone = ImpersonatedUsers.findOne({
adminId: adminId,
});
} else if (!Meteor.settings.public.sandstorm) {
Authentication.checkUserId(req.userId);
user = Users.findOne({ _id: req.userId, isAdmin: true });
}
const exporter = new Exporter(boardId);
if (exporter.canExport(user)) {
if (exporter.canExport(user) || impersonateDone) {
if (impersonateDone) {
ImpersonatedUsers.insert({
adminId: adminId,
boardId: boardId,
reason: 'exportJSON',
});
}
JsonRoutes.sendResult(res, {
code: 200,
data: exporter.build(),
@ -71,22 +85,36 @@ if (Meteor.isServer) {
JsonRoutes.add(
'get',
'/api/boards/:boardId/attachments/:attachmentId/export',
function(req, res) {
function (req, res) {
const boardId = req.params.boardId;
const attachmentId = req.params.attachmentId;
let user = null;
let impersonateDone = false;
let adminId = null;
const loginToken = req.query.authToken;
if (loginToken) {
const hashToken = Accounts._hashLoginToken(loginToken);
user = Meteor.users.findOne({
'services.resume.loginTokens.hashedToken': hashToken,
});
adminId = user._id.toString();
impersonateDone = ImpersonatedUsers.findOne({
adminId: adminId,
});
} else if (!Meteor.settings.public.sandstorm) {
Authentication.checkUserId(req.userId);
user = Users.findOne({ _id: req.userId, isAdmin: true });
}
const exporter = new Exporter(boardId, attachmentId);
if (exporter.canExport(user)) {
if (exporter.canExport(user) || impersonateDone) {
if (impersonateDone) {
ImpersonatedUsers.insert({
adminId: adminId,
boardId: boardId,
attachmentId: attachmentId,
reason: 'exportJSONattachment',
});
}
JsonRoutes.sendResult(res, {
code: 200,
data: exporter.build(),
@ -114,15 +142,21 @@ if (Meteor.isServer) {
* @param {string} authToken the loginToken
* @param {string} delimiter delimiter to use while building export. Default is comma ','
*/
Picker.route('/api/boards/:boardId/export/csv', function(params, req, res) {
Picker.route('/api/boards/:boardId/export/csv', function (params, req, res) {
const boardId = params.boardId;
let user = null;
let impersonateDone = false;
let adminId = null;
const loginToken = params.query.authToken;
if (loginToken) {
const hashToken = Accounts._hashLoginToken(loginToken);
user = Meteor.users.findOne({
'services.resume.loginTokens.hashedToken': hashToken,
});
adminId = user._id.toString();
impersonateDone = ImpersonatedUsers.findOne({
adminId: adminId,
});
} else if (!Meteor.settings.public.sandstorm) {
Authentication.checkUserId(req.userId);
user = Users.findOne({
@ -131,19 +165,31 @@ if (Meteor.isServer) {
});
}
const exporter = new Exporter(boardId);
//if (exporter.canExport(user)) {
body = params.query.delimiter
? exporter.buildCsv(params.query.delimiter)
: exporter.buildCsv();
//'Content-Length': body.length,
res.writeHead(200, {
'Content-Type': params.query.delimiter ? 'text/csv' : 'text/tsv',
});
res.write(body);
res.end();
//} else {
// res.writeHead(403);
// res.end('Permission Error');
//}
if (exporter.canExport(user) || impersonateDone) {
if (impersonateDone) {
// TODO: Checking for CSV or TSV export type does not work:
// let exportType = 'export' + params.query.delimiter ? 'CSV' : 'TSV';
// So logging export to CSV:
let exportType = 'exportCSV';
ImpersonatedUsers.insert({
adminId: adminId,
boardId: boardId,
reason: exportType,
});
}
body = params.query.delimiter
? exporter.buildCsv(params.query.delimiter)
: exporter.buildCsv();
//'Content-Length': body.length,
res.writeHead(200, {
'Content-Type': params.query.delimiter ? 'text/csv' : 'text/tsv',
});
res.write(body);
res.end();
} else {
res.writeHead(403);
res.end('Permission Error');
}
});
}

View file

@ -21,16 +21,21 @@ if (Meteor.isServer) {
* @param {string} authToken the loginToken
*/
const Excel = require('exceljs');
Picker.route('/api/boards/:boardId/exportExcel', function(params, req, res) {
Picker.route('/api/boards/:boardId/exportExcel', function (params, req, res) {
const boardId = params.boardId;
let user = null;
let impersonateDone = false;
let adminId = null;
const loginToken = params.query.authToken;
if (loginToken) {
const hashToken = Accounts._hashLoginToken(loginToken);
user = Meteor.users.findOne({
'services.resume.loginTokens.hashedToken': hashToken,
});
adminId = user._id.toString();
impersonateDone = ImpersonatedUsers.findOne({
adminId: adminId,
});
} else if (!Meteor.settings.public.sandstorm) {
Authentication.checkUserId(req.userId);
user = Users.findOne({
@ -39,7 +44,14 @@ if (Meteor.isServer) {
});
}
const exporterExcel = new ExporterExcel(boardId);
if (exporterExcel.canExport(user)) {
if (exporterExcel.canExport(user) || impersonateDone) {
if (impersonateDone) {
ImpersonatedUsers.insert({
adminId: adminId,
boardId: boardId,
reason: 'exportExcel',
});
}
exporterExcel.build(res);
} else {
res.end(TAPi18n.__('user-can-not-export-excel'));
@ -108,7 +120,7 @@ export class ExporterExcel {
result.subtaskItems = [];
result.triggers = [];
result.actions = [];
result.cards.forEach(card => {
result.cards.forEach((card) => {
result.checklists.push(
...Checklists.find({
cardId: card._id,
@ -125,7 +137,7 @@ export class ExporterExcel {
}).fetch(),
);
});
result.rules.forEach(rule => {
result.rules.forEach((rule) => {
result.triggers.push(
...Triggers.find(
{
@ -149,32 +161,32 @@ export class ExporterExcel {
// 1- only exports users that are linked somehow to that board
// 2- do not export any sensitive information
const users = {};
result.members.forEach(member => {
result.members.forEach((member) => {
users[member.userId] = true;
});
result.lists.forEach(list => {
result.lists.forEach((list) => {
users[list.userId] = true;
});
result.cards.forEach(card => {
result.cards.forEach((card) => {
users[card.userId] = true;
if (card.members) {
card.members.forEach(memberId => {
card.members.forEach((memberId) => {
users[memberId] = true;
});
}
if (card.assignees) {
card.assignees.forEach(memberId => {
card.assignees.forEach((memberId) => {
users[memberId] = true;
});
}
});
result.comments.forEach(comment => {
result.comments.forEach((comment) => {
users[comment.userId] = true;
});
result.activities.forEach(activity => {
result.activities.forEach((activity) => {
users[activity.userId] = true;
});
result.checklists.forEach(checklist => {
result.checklists.forEach((checklist) => {
users[checklist.userId] = true;
});
const byUserIds = {
@ -194,7 +206,7 @@ export class ExporterExcel {
};
result.users = Users.find(byUserIds, userFields)
.fetch()
.map(user => {
.map((user) => {
// user avatar is stored as a relative url, we export absolute
if ((user.profile || {}).avatarUrl) {
user.profile.avatarUrl = FlowRouter.url(user.profile.avatarUrl);
@ -235,7 +247,7 @@ export class ExporterExcel {
},
{
key: 'b',
width: 20,
width: 40,
},
{
key: 'c',
@ -243,25 +255,11 @@ export class ExporterExcel {
},
{
key: 'd',
width: 20,
style: {
font: {
name: TAPi18n.__('excel-font'),
size: '10',
},
numFmt: 'yyyy/mm/dd hh:mm:ss',
},
width: 40,
},
{
key: 'e',
width: 20,
style: {
font: {
name: TAPi18n.__('excel-font'),
size: '10',
},
numFmt: 'yyyy/mm/dd hh:mm:ss',
},
},
{
key: 'f',
@ -321,6 +319,13 @@ export class ExporterExcel {
{
key: 'k',
width: 20,
style: {
font: {
name: TAPi18n.__('excel-font'),
size: '10',
},
numFmt: 'yyyy/mm/dd hh:mm:ss',
},
},
{
key: 'l',
@ -346,6 +351,10 @@ export class ExporterExcel {
key: 'q',
width: 20,
},
{
key: 'r',
width: 20,
},
];
//add title line
@ -392,7 +401,7 @@ export class ExporterExcel {
const jlabel = {};
var isFirst = 1;
for (const klabel in result.labels) {
console.log(klabel);
// console.log(klabel);
if (isFirst == 0) {
jlabel[result.labels[klabel]._id] = `,${result.labels[klabel].name}`;
} else {
@ -430,7 +439,7 @@ export class ExporterExcel {
size: 10,
bold: true,
};
ws.mergeCells('F3:Q3');
ws.mergeCells('F3:R3');
ws.getCell('B3').style = {
font: {
name: TAPi18n.__('excel-font'),
@ -509,6 +518,7 @@ export class ExporterExcel {
TAPi18n.__('number'),
TAPi18n.__('title'),
TAPi18n.__('description'),
TAPi18n.__('parent-card'),
TAPi18n.__('owner'),
TAPi18n.__('createdAt'),
TAPi18n.__('last-modified-at'),
@ -542,6 +552,7 @@ export class ExporterExcel {
allBorder('O5');
allBorder('P5');
allBorder('Q5');
allBorder('R5');
cellCenter('A5');
cellCenter('B5');
cellCenter('C5');
@ -559,6 +570,7 @@ export class ExporterExcel {
cellCenter('O5');
cellCenter('P5');
cellCenter('Q5');
cellCenter('R5');
ws.getRow(5).font = {
name: TAPi18n.__('excel-font'),
size: 12,
@ -586,6 +598,13 @@ export class ExporterExcel {
jclabel += jlabel[jcard.labelIds[jl]];
jclabel += ' ';
}
//get parent name
if (jcard.parentId) {
const parentCard = result.cards.find(
(card) => card._id === jcard.parentId,
);
jcard.parentCardTitle = parentCard ? parentCard.title : '';
}
//add card detail
const t = Number(i) + 1;
@ -593,6 +612,7 @@ export class ExporterExcel {
t.toString(),
jcard.title,
jcard.description,
jcard.parentCardTitle,
jmeml[jcard.userId],
addTZhours(jcard.createdAt),
addTZhours(jcard.dateLastActivity),
@ -627,6 +647,7 @@ export class ExporterExcel {
allBorder(`O${y}`);
allBorder(`P${y}`);
allBorder(`Q${y}`);
allBorder(`R${y}`);
cellCenter(`A${y}`);
ws.getCell(`B${y}`).alignment = {
wrapText: true,
@ -634,17 +655,17 @@ export class ExporterExcel {
ws.getCell(`C${y}`).alignment = {
wrapText: true,
};
ws.getCell(`L${y}`).alignment = {
wrapText: true,
};
ws.getCell(`M${y}`).alignment = {
wrapText: true,
};
ws.getCell(`N${y}`).alignment = {
wrapText: true,
};
ws.getCell(`O${y}`).alignment = {
wrapText: true,
};
}
workbook.xlsx.write(res).then(function() {});
workbook.xlsx.write(res).then(function () {});
}
canExport(user) {

View file

@ -38,7 +38,7 @@ export class Exporter {
// [Old] for attachments we only export IDs and absolute url to original doc
// [New] Encode attachment to base64
const getBase64Data = function(doc, callback) {
const getBase64Data = function (doc, callback) {
let buffer = Buffer.allocUnsafe(0);
buffer.fill(0);
@ -49,14 +49,14 @@ export class Exporter {
);
const tmpWriteable = fs.createWriteStream(tmpFile);
const readStream = doc.createReadStream();
readStream.on('data', function(chunk) {
readStream.on('data', function (chunk) {
buffer = Buffer.concat([buffer, chunk]);
});
readStream.on('error', function() {
readStream.on('error', function () {
callback(null, null);
});
readStream.on('end', function() {
readStream.on('end', function () {
// done
fs.unlink(tmpFile, () => {
//ignored
@ -72,7 +72,7 @@ export class Exporter {
: byBoard;
result.attachments = Attachments.find(byBoardAndAttachment)
.fetch()
.map(attachment => {
.map((attachment) => {
let filebase64 = null;
filebase64 = getBase64DataSync(attachment);
@ -105,7 +105,7 @@ export class Exporter {
result.subtaskItems = [];
result.triggers = [];
result.actions = [];
result.cards.forEach(card => {
result.cards.forEach((card) => {
result.checklists.push(
...Checklists.find({
cardId: card._id,
@ -122,7 +122,7 @@ export class Exporter {
}).fetch(),
);
});
result.rules.forEach(rule => {
result.rules.forEach((rule) => {
result.triggers.push(
...Triggers.find(
{
@ -146,27 +146,27 @@ export class Exporter {
// 1- only exports users that are linked somehow to that board
// 2- do not export any sensitive information
const users = {};
result.members.forEach(member => {
result.members.forEach((member) => {
users[member.userId] = true;
});
result.lists.forEach(list => {
result.lists.forEach((list) => {
users[list.userId] = true;
});
result.cards.forEach(card => {
result.cards.forEach((card) => {
users[card.userId] = true;
if (card.members) {
card.members.forEach(memberId => {
card.members.forEach((memberId) => {
users[memberId] = true;
});
}
});
result.comments.forEach(comment => {
result.comments.forEach((comment) => {
users[comment.userId] = true;
});
result.activities.forEach(activity => {
result.activities.forEach((activity) => {
users[activity.userId] = true;
});
result.checklists.forEach(checklist => {
result.checklists.forEach((checklist) => {
users[checklist.userId] = true;
});
const byUserIds = {
@ -187,7 +187,7 @@ export class Exporter {
};
result.users = Users.find(byUserIds, userFields)
.fetch()
.map(user => {
.map((user) => {
// user avatar is stored as a relative url, we export absolute
if ((user.profile || {}).avatarUrl) {
user.profile.avatarUrl = FlowRouter.url(user.profile.avatarUrl);
@ -259,14 +259,14 @@ export class Exporter {
);
const customFieldMap = {};
let i = 0;
result.customFields.forEach(customField => {
result.customFields.forEach((customField) => {
customFieldMap[customField._id] = {
position: i,
type: customField.type,
};
if (customField.type === 'dropdown') {
let options = '';
customField.settings.dropdownItems.forEach(item => {
customField.settings.dropdownItems.forEach((item) => {
options = options === '' ? item.name : `${`${options}/${item.name}`}`;
});
columnHeaders.push(
@ -308,7 +308,7 @@ export class Exporter {
TAPi18n.__('archived'),
*/
result.cards.forEach(card => {
result.cards.forEach((card) => {
const currentRow = [];
currentRow.push(card.title);
currentRow.push(card.description);
@ -324,19 +324,19 @@ export class Exporter {
currentRow.push(card.requestedBy ? card.requestedBy : ' ');
currentRow.push(card.assignedBy ? card.assignedBy : ' ');
let usernames = '';
card.members.forEach(memberId => {
card.members.forEach((memberId) => {
const user = result.users.find(({ _id }) => _id === memberId);
usernames = `${usernames + user.username} `;
});
currentRow.push(usernames.trim());
let assignees = '';
card.assignees.forEach(assigneeId => {
card.assignees.forEach((assigneeId) => {
const user = result.users.find(({ _id }) => _id === assigneeId);
assignees = `${assignees + user.username} `;
});
currentRow.push(assignees.trim());
let labels = '';
card.labelIds.forEach(labelId => {
card.labelIds.forEach((labelId) => {
const label = result.labels.find(({ _id }) => _id === labelId);
labels = `${labels + label.name}-${label.color} `;
});
@ -354,11 +354,11 @@ export class Exporter {
if (card.vote && card.vote.question !== '') {
let positiveVoters = '';
let negativeVoters = '';
card.vote.positive.forEach(userId => {
card.vote.positive.forEach((userId) => {
const user = result.users.find(({ _id }) => _id === userId);
positiveVoters = `${positiveVoters + user.username} `;
});
card.vote.negative.forEach(userId => {
card.vote.negative.forEach((userId) => {
const user = result.users.find(({ _id }) => _id === userId);
negativeVoters = `${negativeVoters + user.username} `;
});
@ -378,12 +378,11 @@ export class Exporter {
currentRow.push(card.archived ? 'true' : 'false');
//Custom fields
const customFieldValuesToPush = new Array(result.customFields.length);
card.customFields.forEach(field => {
card.customFields.forEach((field) => {
if (field.value !== null) {
if (customFieldMap[field._id].type === 'date') {
customFieldValuesToPush[
customFieldMap[field._id].position
] = moment(field.value).format();
customFieldValuesToPush[customFieldMap[field._id].position] =
moment(field.value).format();
} else if (customFieldMap[field._id].type === 'dropdown') {
const dropdownOptions = result.customFields.find(
({ _id }) => _id === field._id,
@ -391,9 +390,8 @@ export class Exporter {
const fieldValue = dropdownOptions.find(
({ _id }) => _id === field.value,
).name;
customFieldValuesToPush[
customFieldMap[field._id].position
] = fieldValue;
customFieldValuesToPush[customFieldMap[field._id].position] =
fieldValue;
} else {
customFieldValuesToPush[customFieldMap[field._id].position] =
field.value;

View file

@ -0,0 +1,79 @@
ImpersonatedUsers = new Mongo.Collection('impersonatedUsers');
/**
* A Impersonated User in wekan
*/
ImpersonatedUsers.attachSchema(
new SimpleSchema({
adminId: {
/**
* the admin userid that impersonates
*/
type: String,
optional: true,
},
userId: {
/**
* the userId that is impersonated
*/
type: String,
optional: true,
},
boardId: {
/**
* the boardId that was exported by anyone that has sometime impersonated
*/
type: String,
optional: true,
},
attachmentId: {
/**
* the attachmentId that was exported by anyone that has sometime impersonated
*/
type: String,
optional: true,
},
reason: {
/**
* the reason why impersonated, like exportJSON
*/
type: String,
optional: true,
},
createdAt: {
/**
* creation date of the impersonation
*/
type: Date,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert) {
return new Date();
} else if (this.isUpsert) {
return {
$setOnInsert: new Date(),
};
} else {
this.unset();
}
},
},
modifiedAt: {
/**
* modified date of the impersonation
*/
type: Date,
denyUpdate: false,
// eslint-disable-next-line consistent-return
autoValue() {
if (this.isInsert || this.isUpsert || this.isUpdate) {
return new Date();
} else {
this.unset();
}
},
},
}),
);
export default ImpersonatedUsers;

View file

@ -157,9 +157,14 @@ export class TrelloCreator {
// You must call parseActions before calling this one.
createBoardAndLabels(trelloBoard) {
let color = 'blue';
if (this.getColor(trelloBoard.prefs.background) !== undefined) {
color = this.getColor(trelloBoard.prefs.background);
}
const boardToCreate = {
archived: trelloBoard.closed,
color: this.getColor(trelloBoard.prefs.background),
color: color,
// very old boards won't have a creation activity so no creation date
createdAt: this._now(this.createdAt.board),
labels: [],

View file

@ -1,4 +1,5 @@
import { SyncedCron } from 'meteor/percolate:synced-cron';
import ImpersonatedUsers from './impersonatedUsers';
// Sandstorm context is detected using the METEOR_SETTINGS environment variable
// in the package definition.
@ -67,7 +68,9 @@ Users.attachSchema(
if (this.isInsert) {
return new Date();
} else if (this.isUpsert) {
return { $setOnInsert: new Date() };
return {
$setOnInsert: new Date(),
};
} else {
this.unset();
}
@ -350,7 +353,9 @@ Users.attachSchema(
Users.allow({
update(userId, doc) {
const user = Users.findOne({ _id: userId });
const user = Users.findOne({
_id: userId,
});
if ((user && user.isAdmin) || (Meteor.user() && Meteor.user().isAdmin))
return true;
if (!user) {
@ -359,10 +364,18 @@ Users.allow({
return doc._id === userId;
},
remove(userId, doc) {
const adminsNumber = Users.find({ isAdmin: true }).count();
const adminsNumber = Users.find({
isAdmin: true,
}).count();
const { isAdmin } = Users.findOne(
{ _id: userId },
{ fields: { isAdmin: 1 } },
{
_id: userId,
},
{
fields: {
isAdmin: 1,
},
},
);
// Prevents remove of the only one administrator
@ -440,7 +453,7 @@ if (Meteor.isClient) {
});
}
Users.parseImportUsernames = usernamesString => {
Users.parseImportUsernames = (usernamesString) => {
return usernamesString.trim().split(new RegExp('\\s*[,;]\\s*'));
};
@ -454,17 +467,30 @@ Users.helpers({
boards() {
return Boards.find(
{ 'members.userId': this._id },
{ sort: { sort: 1 /* boards default sorting */ } },
{
'members.userId': this._id,
},
{
sort: {
sort: 1 /* boards default sorting */,
},
},
);
},
starredBoards() {
const { starredBoards = [] } = this.profile || {};
return Boards.find(
{ archived: false, _id: { $in: starredBoards } },
{
sort: { sort: 1 /* boards default sorting */ },
archived: false,
_id: {
$in: starredBoards,
},
},
{
sort: {
sort: 1 /* boards default sorting */,
},
},
);
},
@ -477,9 +503,16 @@ Users.helpers({
invitedBoards() {
const { invitedBoards = [] } = this.profile || {};
return Boards.find(
{ archived: false, _id: { $in: invitedBoards } },
{
sort: { sort: 1 /* boards default sorting */ },
archived: false,
_id: {
$in: invitedBoards,
},
},
{
sort: {
sort: 1 /* boards default sorting */,
},
},
);
},
@ -611,7 +644,9 @@ Users.helpers({
},
remove() {
User.remove({ _id: this._id });
User.remove({
_id: this._id,
});
},
});
@ -714,7 +749,9 @@ Users.mutations({
addNotification(activityId) {
return {
$addToSet: {
'profile.notifications': { activity: activityId },
'profile.notifications': {
activity: activityId,
},
},
};
},
@ -722,7 +759,9 @@ Users.mutations({
removeNotification(activityId) {
return {
$pull: {
'profile.notifications': { activity: activityId },
'profile.notifications': {
activity: activityId,
},
},
};
},
@ -744,15 +783,27 @@ Users.mutations({
},
setAvatarUrl(avatarUrl) {
return { $set: { 'profile.avatarUrl': avatarUrl } };
return {
$set: {
'profile.avatarUrl': avatarUrl,
},
};
},
setShowCardsCountAt(limit) {
return { $set: { 'profile.showCardsCountAt': limit } };
return {
$set: {
'profile.showCardsCountAt': limit,
},
};
},
setStartDayOfWeek(startDay) {
return { $set: { 'profile.startDayOfWeek': startDay } };
return {
$set: {
'profile.startDayOfWeek': startDay,
},
};
},
setBoardView(view) {
@ -801,15 +852,33 @@ if (Meteor.isServer) {
if (Meteor.user() && Meteor.user().isAdmin) {
// If setting is missing, add it
Users.update(
{ 'profile.hiddenSystemMessages': { $exists: false } },
{ $set: { 'profile.hiddenSystemMessages': true } },
{ multi: true },
{
'profile.hiddenSystemMessages': {
$exists: false,
},
},
{
$set: {
'profile.hiddenSystemMessages': true,
},
},
{
multi: true,
},
);
// If setting is false, set it to true
Users.update(
{ 'profile.hiddenSystemMessages': false },
{ $set: { 'profile.hiddenSystemMessages': true } },
{ multi: true },
{
'profile.hiddenSystemMessages': false,
},
{
$set: {
'profile.hiddenSystemMessages': true,
},
},
{
multi: true,
},
);
return true;
} else {
@ -836,8 +905,12 @@ if (Meteor.isServer) {
check(email, String);
check(importUsernames, Array);
const nUsersWithUsername = Users.find({ username }).count();
const nUsersWithEmail = Users.find({ email }).count();
const nUsersWithUsername = Users.find({
username,
}).count();
const nUsersWithEmail = Users.find({
email,
}).count();
if (nUsersWithUsername > 0) {
throw new Meteor.Error('username-already-taken');
} else if (nUsersWithEmail > 0) {
@ -851,7 +924,11 @@ if (Meteor.isServer) {
email: email.toLowerCase(),
from: 'admin',
});
const user = Users.findOne(username) || Users.findOne({ username });
const user =
Users.findOne(username) ||
Users.findOne({
username,
});
if (user) {
Users.update(user._id, {
$set: {
@ -868,11 +945,17 @@ if (Meteor.isServer) {
if (Meteor.user() && Meteor.user().isAdmin) {
check(username, String);
check(userId, String);
const nUsersWithUsername = Users.find({ username }).count();
const nUsersWithUsername = Users.find({
username,
}).count();
if (nUsersWithUsername > 0) {
throw new Meteor.Error('username-already-taken');
} else {
Users.update(userId, { $set: { username } });
Users.update(userId, {
$set: {
username,
},
});
}
}
},
@ -883,8 +966,14 @@ if (Meteor.isServer) {
}
check(email, String);
const existingUser = Users.findOne(
{ 'emails.address': email },
{ fields: { _id: 1 } },
{
'emails.address': email,
},
{
fields: {
_id: 1,
},
},
);
if (existingUser) {
throw new Meteor.Error('email-already-taken');
@ -963,7 +1052,9 @@ if (Meteor.isServer) {
board &&
board.members &&
_.contains(_.pluck(board.members, 'userId'), inviter._id) &&
_.where(board.members, { userId: inviter._id })[0].isActive;
_.where(board.members, {
userId: inviter._id,
})[0].isActive;
// GitHub issue 2060
//_.where(board.members, { userId: inviter._id })[0].isAdmin;
if (!allowInvite) throw new Meteor.Error('error-board-notAMember');
@ -973,22 +1064,39 @@ if (Meteor.isServer) {
const posAt = username.indexOf('@');
let user = null;
if (posAt >= 0) {
user = Users.findOne({ emails: { $elemMatch: { address: username } } });
user = Users.findOne({
emails: {
$elemMatch: {
address: username,
},
},
});
} else {
user = Users.findOne(username) || Users.findOne({ username });
user =
Users.findOne(username) ||
Users.findOne({
username,
});
}
if (user) {
if (user._id === inviter._id)
throw new Meteor.Error('error-user-notAllowSelf');
} else {
if (posAt <= 0) throw new Meteor.Error('error-user-doesNotExist');
if (Settings.findOne({ disableRegistration: true })) {
if (
Settings.findOne({
disableRegistration: true,
})
) {
throw new Meteor.Error('error-user-notCreated');
}
// Set in lowercase email before creating account
const email = username.toLowerCase();
username = email.substring(0, posAt);
const newUserId = Accounts.createUser({ username, email });
const newUserId = Accounts.createUser({
username,
email,
});
if (!newUserId) throw new Meteor.Error('error-user-notCreated');
// assume new user speak same language with inviter
if (inviter.profile && inviter.profile.language) {
@ -1032,7 +1140,10 @@ if (Meteor.isServer) {
} catch (e) {
throw new Meteor.Error('email-fail', e.message);
}
return { username: user.username, email: user.emails[0].address };
return {
username: user.username,
email: user.emails[0].address,
};
},
impersonate(userId) {
check(userId, String);
@ -1042,8 +1153,16 @@ if (Meteor.isServer) {
if (!Meteor.user().isAdmin)
throw new Meteor.Error(403, 'Permission denied');
ImpersonatedUsers.insert({ adminId: Meteor.user()._id, userId: userId, reason: 'clickedImpersonate' });
this.setUserId(userId);
},
isImpersonated(userId) {
check(userId, String);
const isImpersonated = ImpersonatedUsers.findOne({
userId: userId,
});
return isImpersonated;
},
});
Accounts.onCreateUser((options, user) => {
const userCount = Users.find().count();
@ -1059,7 +1178,12 @@ if (Meteor.isServer) {
}
email = email.toLowerCase();
user.username = user.services.oidc.username;
user.emails = [{ address: email, verified: true }];
user.emails = [
{
address: email,
verified: true,
},
];
const initials = user.services.oidc.fullname
.split(/\s+/)
.reduce((memo, word) => {
@ -1075,7 +1199,14 @@ if (Meteor.isServer) {
// see if any existing user has this email address or username, otherwise create new
const existingUser = Meteor.users.findOne({
$or: [{ 'emails.address': email }, { username: user.username }],
$or: [
{
'emails.address': email,
},
{
username: user.username,
},
],
});
if (!existingUser) return user;
@ -1087,8 +1218,12 @@ if (Meteor.isServer) {
existingUser.profile = user.profile;
existingUser.authenticationMethod = user.authenticationMethod;
Meteor.users.remove({ _id: user._id });
Meteor.users.remove({ _id: existingUser._id }); // is going to be created again
Meteor.users.remove({
_id: user._id,
});
Meteor.users.remove({
_id: existingUser._id,
}); // is going to be created again
return existingUser;
}
@ -1127,13 +1262,17 @@ if (Meteor.isServer) {
"The invitation code doesn't exist",
);
} else {
user.profile = { icode: options.profile.invitationcode };
user.profile = {
icode: options.profile.invitationcode,
};
user.profile.boardView = 'board-view-swimlanes';
// Deletes the invitation code after the user was created successfully.
setTimeout(
Meteor.bindEnvironment(() => {
InvitationCodes.remove({ _id: invitationCode._id });
InvitationCodes.remove({
_id: invitationCode._id,
});
}),
200,
);
@ -1153,7 +1292,7 @@ const addCronJob = _.debounce(
SyncedCron.add({
name: 'notification_cleanup',
schedule: parser => parser.text('every 1 days'),
schedule: (parser) => parser.text('every 1 days'),
job: () => {
for (const user of Users.find()) {
if (!user.profile || !user.profile.notifications) continue;
@ -1178,15 +1317,19 @@ const addCronJob = _.debounce(
if (Meteor.isServer) {
// Let mongoDB ensure username unicity
Meteor.startup(() => {
allowedSortValues.forEach(value => {
allowedSortValues.forEach((value) => {
Lists._collection._ensureIndex(value);
});
Users._collection._ensureIndex({ modifiedAt: -1 });
Users._collection._ensureIndex({
modifiedAt: -1,
});
Users._collection._ensureIndex(
{
username: 1,
},
{ unique: true },
{
unique: true,
},
);
Meteor.defer(() => {
addCronJob();
@ -1215,7 +1358,7 @@ if (Meteor.isServer) {
// counter.
// We need to run this code on the server only, otherwise the incrementation
// will be done twice.
Users.after.update(function(userId, user, fieldNames) {
Users.after.update(function (userId, user, fieldNames) {
// The `starredBoards` list is hosted on the `profile` field. If this
// field hasn't been modificated we don't need to run this hook.
if (!_.contains(fieldNames, 'profile')) return;
@ -1233,8 +1376,12 @@ if (Meteor.isServer) {
// b. We use it to find deleted and newly inserted ids by using it in one
// direction and then in the other.
function incrementBoards(boardsIds, inc) {
boardsIds.forEach(boardId => {
Boards.update(boardId, { $inc: { stars: inc } });
boardsIds.forEach((boardId) => {
Boards.update(boardId, {
$inc: {
stars: inc,
},
});
});
}
@ -1258,23 +1405,23 @@ if (Meteor.isServer) {
fakeUserId.withValue(doc._id, () => {
/*
// Insert the Welcome Board
Boards.insert({
title: TAPi18n.__('welcome-board'),
permission: 'private',
}, fakeUser, (err, boardId) => {
// Insert the Welcome Board
Boards.insert({
title: TAPi18n.__('welcome-board'),
permission: 'private',
}, fakeUser, (err, boardId) => {
Swimlanes.insert({
title: TAPi18n.__('welcome-swimlane'),
boardId,
sort: 1,
}, fakeUser);
Swimlanes.insert({
title: TAPi18n.__('welcome-swimlane'),
boardId,
sort: 1,
}, fakeUser);
['welcome-list1', 'welcome-list2'].forEach((title, titleIndex) => {
Lists.insert({title: TAPi18n.__(title), boardId, sort: titleIndex}, fakeUser);
});
});
*/
['welcome-list1', 'welcome-list2'].forEach((title, titleIndex) => {
Lists.insert({title: TAPi18n.__(title), boardId, sort: titleIndex}, fakeUser);
});
});
*/
const Future = require('fibers/future');
const future1 = new Future();
@ -1290,7 +1437,9 @@ if (Meteor.isServer) {
(err, boardId) => {
// Insert the reference to our templates board
Users.update(fakeUserId.get(), {
$set: { 'profile.templatesBoardId': boardId },
$set: {
'profile.templatesBoardId': boardId,
},
});
// Insert the card templates swimlane
@ -1305,7 +1454,9 @@ if (Meteor.isServer) {
(err, swimlaneId) => {
// Insert the reference to out card templates swimlane
Users.update(fakeUserId.get(), {
$set: { 'profile.cardTemplatesSwimlaneId': swimlaneId },
$set: {
'profile.cardTemplatesSwimlaneId': swimlaneId,
},
});
future1.return();
},
@ -1323,7 +1474,9 @@ if (Meteor.isServer) {
(err, swimlaneId) => {
// Insert the reference to out list templates swimlane
Users.update(fakeUserId.get(), {
$set: { 'profile.listTemplatesSwimlaneId': swimlaneId },
$set: {
'profile.listTemplatesSwimlaneId': swimlaneId,
},
});
future2.return();
},
@ -1341,7 +1494,9 @@ if (Meteor.isServer) {
(err, swimlaneId) => {
// Insert the reference to out board templates swimlane
Users.update(fakeUserId.get(), {
$set: { 'profile.boardTemplatesSwimlaneId': swimlaneId },
$set: {
'profile.boardTemplatesSwimlaneId': swimlaneId,
},
});
future3.return();
},
@ -1358,7 +1513,9 @@ if (Meteor.isServer) {
Users.after.insert((userId, doc) => {
// HACK
doc = Users.findOne({ _id: doc._id });
doc = Users.findOne({
_id: doc._id,
});
if (doc.createdThroughApi) {
// The admin user should be able to create a user despite disabling registration because
// it is two different things (registration and creation).
@ -1366,7 +1523,11 @@ if (Meteor.isServer) {
// the disableRegistration check.
// Issue : https://github.com/wekan/wekan/issues/1232
// PR : https://github.com/wekan/wekan/pull/1251
Users.update(doc._id, { $set: { createdThroughApi: '' } });
Users.update(doc._id, {
$set: {
createdThroughApi: '',
},
});
return;
}
@ -1382,7 +1543,7 @@ if (Meteor.isServer) {
if (!invitationCode) {
throw new Meteor.Error('error-invitation-code-not-exist');
} else {
invitationCode.boardsToBeInvited.forEach(boardId => {
invitationCode.boardsToBeInvited.forEach((boardId) => {
const board = Boards.findOne(boardId);
board.addMember(doc._id);
});
@ -1390,8 +1551,16 @@ if (Meteor.isServer) {
doc.profile = {};
}
doc.profile.invitedBoards = invitationCode.boardsToBeInvited;
Users.update(doc._id, { $set: { profile: doc.profile } });
InvitationCodes.update(invitationCode._id, { $set: { valid: false } });
Users.update(doc._id, {
$set: {
profile: doc.profile,
},
});
InvitationCodes.update(invitationCode._id, {
$set: {
valid: false,
},
});
}
}
});
@ -1400,12 +1569,14 @@ if (Meteor.isServer) {
// USERS REST API
if (Meteor.isServer) {
// Middleware which checks that API is enabled.
JsonRoutes.Middleware.use(function(req, res, next) {
JsonRoutes.Middleware.use(function (req, res, next) {
const api = req.url.startsWith('/api');
if ((api === true && process.env.WITH_API === 'true') || api === false) {
return next();
} else {
res.writeHead(301, { Location: '/' });
res.writeHead(301, {
Location: '/',
});
return res.end();
}
});
@ -1416,10 +1587,12 @@ if (Meteor.isServer) {
* @summary returns the current user
* @return_type Users
*/
JsonRoutes.add('GET', '/api/user', function(req, res) {
JsonRoutes.add('GET', '/api/user', function (req, res) {
try {
Authentication.checkLoggedIn(req.userId);
const data = Meteor.users.findOne({ _id: req.userId });
const data = Meteor.users.findOne({
_id: req.userId,
});
delete data.services;
// get all boards where the user is member of
@ -1429,11 +1602,14 @@ if (Meteor.isServer) {
'members.userId': req.userId,
},
{
fields: { _id: 1, members: 1 },
fields: {
_id: 1,
members: 1,
},
},
);
boards = boards.map(b => {
const u = b.members.find(m => m.userId === req.userId);
boards = boards.map((b) => {
const u = b.members.find((m) => m.userId === req.userId);
delete u.userId;
u.boardId = b._id;
return u;
@ -1461,13 +1637,16 @@ if (Meteor.isServer) {
* @return_type [{ _id: string,
* username: string}]
*/
JsonRoutes.add('GET', '/api/users', function(req, res) {
JsonRoutes.add('GET', '/api/users', function (req, res) {
try {
Authentication.checkUserId(req.userId);
JsonRoutes.sendResult(res, {
code: 200,
data: Meteor.users.find({}).map(function(doc) {
return { _id: doc._id, username: doc.username };
data: Meteor.users.find({}).map(function (doc) {
return {
_id: doc._id,
username: doc.username,
};
}),
});
} catch (error) {
@ -1488,13 +1667,17 @@ if (Meteor.isServer) {
* @param {string} userId the user ID or username
* @return_type Users
*/
JsonRoutes.add('GET', '/api/users/:userId', function(req, res) {
JsonRoutes.add('GET', '/api/users/:userId', function (req, res) {
try {
Authentication.checkUserId(req.userId);
let id = req.params.userId;
let user = Meteor.users.findOne({ _id: id });
let user = Meteor.users.findOne({
_id: id,
});
if (!user) {
user = Meteor.users.findOne({ username: id });
user = Meteor.users.findOne({
username: id,
});
id = user._id;
}
@ -1505,11 +1688,14 @@ if (Meteor.isServer) {
'members.userId': id,
},
{
fields: { _id: 1, members: 1 },
fields: {
_id: 1,
members: 1,
},
},
);
boards = boards.map(b => {
const u = b.members.find(m => m.userId === id);
boards = boards.map((b) => {
const u = b.members.find((m) => m.userId === id);
delete u.userId;
u.boardId = b._id;
return u;
@ -1545,12 +1731,14 @@ if (Meteor.isServer) {
* @return_type {_id: string,
* title: string}
*/
JsonRoutes.add('PUT', '/api/users/:userId', function(req, res) {
JsonRoutes.add('PUT', '/api/users/:userId', function (req, res) {
try {
Authentication.checkUserId(req.userId);
const id = req.params.userId;
const action = req.body.action;
let data = Meteor.users.findOne({ _id: id });
let data = Meteor.users.findOne({
_id: id,
});
if (data !== undefined) {
if (action === 'takeOwnership') {
data = Boards.find(
@ -1558,8 +1746,12 @@ if (Meteor.isServer) {
'members.userId': id,
'members.isAdmin': true,
},
{ sort: { sort: 1 /* boards default sorting */ } },
).map(function(board) {
{
sort: {
sort: 1 /* boards default sorting */,
},
},
).map(function (board) {
if (board.hasMember(req.userId)) {
board.removeMember(req.userId);
}
@ -1572,7 +1764,9 @@ if (Meteor.isServer) {
} else {
if (action === 'disableLogin' && id !== req.userId) {
Users.update(
{ _id: id },
{
_id: id,
},
{
$set: {
loginDisabled: true,
@ -1581,9 +1775,20 @@ if (Meteor.isServer) {
},
);
} else if (action === 'enableLogin') {
Users.update({ _id: id }, { $set: { loginDisabled: '' } });
Users.update(
{
_id: id,
},
{
$set: {
loginDisabled: '',
},
},
);
}
data = Meteor.users.findOne({ _id: id });
data = Meteor.users.findOne({
_id: id,
});
}
}
JsonRoutes.sendResult(res, {
@ -1617,53 +1822,57 @@ if (Meteor.isServer) {
* @return_type {_id: string,
* title: string}
*/
JsonRoutes.add('POST', '/api/boards/:boardId/members/:userId/add', function(
req,
res,
) {
try {
Authentication.checkUserId(req.userId);
const userId = req.params.userId;
const boardId = req.params.boardId;
const action = req.body.action;
const { isAdmin, isNoComments, isCommentOnly } = req.body;
let data = Meteor.users.findOne({ _id: userId });
if (data !== undefined) {
if (action === 'add') {
data = Boards.find({
_id: boardId,
}).map(function(board) {
if (!board.hasMember(userId)) {
board.addMember(userId);
function isTrue(data) {
return data.toLowerCase() === 'true';
JsonRoutes.add(
'POST',
'/api/boards/:boardId/members/:userId/add',
function (req, res) {
try {
Authentication.checkUserId(req.userId);
const userId = req.params.userId;
const boardId = req.params.boardId;
const action = req.body.action;
const { isAdmin, isNoComments, isCommentOnly } = req.body;
let data = Meteor.users.findOne({
_id: userId,
});
if (data !== undefined) {
if (action === 'add') {
data = Boards.find({
_id: boardId,
}).map(function (board) {
if (!board.hasMember(userId)) {
board.addMember(userId);
function isTrue(data) {
return data.toLowerCase() === 'true';
}
board.setMemberPermission(
userId,
isTrue(isAdmin),
isTrue(isNoComments),
isTrue(isCommentOnly),
userId,
);
}
board.setMemberPermission(
userId,
isTrue(isAdmin),
isTrue(isNoComments),
isTrue(isCommentOnly),
userId,
);
}
return {
_id: board._id,
title: board.title,
};
});
return {
_id: board._id,
title: board.title,
};
});
}
}
JsonRoutes.sendResult(res, {
code: 200,
data: query,
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
JsonRoutes.sendResult(res, {
code: 200,
data: query,
});
} catch (error) {
JsonRoutes.sendResult(res, {
code: 200,
data: error,
});
}
});
},
);
/**
* @operation remove_board_member
@ -1682,18 +1891,20 @@ if (Meteor.isServer) {
JsonRoutes.add(
'POST',
'/api/boards/:boardId/members/:userId/remove',
function(req, res) {
function (req, res) {
try {
Authentication.checkUserId(req.userId);
const userId = req.params.userId;
const boardId = req.params.boardId;
const action = req.body.action;
let data = Meteor.users.findOne({ _id: userId });
let data = Meteor.users.findOne({
_id: userId,
});
if (data !== undefined) {
if (action === 'remove') {
data = Boards.find({
_id: boardId,
}).map(function(board) {
}).map(function (board) {
if (board.hasMember(userId)) {
board.removeMember(userId);
}
@ -1729,7 +1940,7 @@ if (Meteor.isServer) {
* @param {string} password the password of the new user
* @return_type {_id: string}
*/
JsonRoutes.add('POST', '/api/users/', function(req, res) {
JsonRoutes.add('POST', '/api/users/', function (req, res) {
try {
Authentication.checkUserId(req.userId);
const id = Accounts.createUser({
@ -1762,7 +1973,7 @@ if (Meteor.isServer) {
* @param {string} userId the ID of the user to delete
* @return_type {_id: string}
*/
JsonRoutes.add('DELETE', '/api/users/:userId', function(req, res) {
JsonRoutes.add('DELETE', '/api/users/:userId', function (req, res) {
try {
Authentication.checkUserId(req.userId);
const id = req.params.userId;
@ -1800,7 +2011,7 @@ if (Meteor.isServer) {
* @param {string} userId the ID of the user to create token for.
* @return_type {_id: string}
*/
JsonRoutes.add('POST', '/api/createtoken/:userId', function(req, res) {
JsonRoutes.add('POST', '/api/createtoken/:userId', function (req, res) {
try {
Authentication.checkUserId(req.userId);
const id = req.params.userId;

2
package-lock.json generated
View file

@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v5.28.0",
"version": "v5.29.0",
"lockfileVersion": 2,
"requires": true,
"packages": {

View file

@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v5.28.0",
"version": "v5.29.0",
"description": "Open-Source kanban",
"private": true,
"repository": {

View file

@ -7,7 +7,7 @@
<meta charset="utf-8">
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Wekan REST API v5.28</title>
<title>Wekan REST API v5.29</title>
<style>
</style>
@ -1550,7 +1550,7 @@ var n=this.pipeline.run(e.tokenizer(t)),r=new e.Vector,i=[],o=this._fields.reduc
<ul class="toc-list-h1">
<li>
<a href="#wekan-rest-api" class="toc-h1 toc-link" data-title="Wekan REST API v5.28">Wekan REST API v5.28</a>
<a href="#wekan-rest-api" class="toc-h1 toc-link" data-title="Wekan REST API v5.29">Wekan REST API v5.29</a>
</li>
@ -2098,7 +2098,7 @@ var n=this.pipeline.run(e.tokenizer(t)),r=new e.Vector,i=[],o=this._fields.reduc
<div class="page-wrapper">
<div class="dark-box"></div>
<div class="content">
<h1 id="wekan-rest-api">Wekan REST API v5.28</h1>
<h1 id="wekan-rest-api">Wekan REST API v5.29</h1>
<blockquote>
<p>Scroll down for code samples, example requests and responses. Select a language for code samples from the tabs above or the mobile navigation menu.</p>
</blockquote>
@ -19203,8 +19203,8 @@ UserSecurity
</tr>
<tr>
<td>sort</td>
<td>number</td>
<td>true</td>
<td>number¦null</td>
<td>false</td>
<td>none</td>
<td>Sort value</td>
</tr>

View file

@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v5.28
version: v5.29
description: |
The REST API allows you to control and extend Wekan with ease.
@ -3156,6 +3156,7 @@ definitions:
description: |
Sort value
type: number
x-nullable: true
subtaskSort:
description: |
subtask sort value
@ -3182,7 +3183,6 @@ definitions:
- modifiedAt
- dateLastActivity
- userId
- sort
- type
CardsVote:
type: object

View file

@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 528,
appVersion = 529,
# Increment this for every release.
appMarketingVersion = (defaultText = "5.28.0~2021-05-07"),
appMarketingVersion = (defaultText = "5.29.0~2021-05-29"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,

View file

@ -1,5 +1,5 @@
name: wekan
version: '5.28'
version: '5.29'
summary: The open-source kanban
description: |
Wekan is an open-source and collaborative kanban board application.