initial commit
This commit is contained in:
commit
5d3926d2a4
12
.circleci/config.yml
Normal file
12
.circleci/config.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
version: 2.1
|
||||||
|
|
||||||
|
orbs:
|
||||||
|
plugin-ci: mattermost/plugin-ci@0.1.0
|
||||||
|
|
||||||
|
workflows:
|
||||||
|
version: 2
|
||||||
|
ci:
|
||||||
|
jobs:
|
||||||
|
- plugin-ci/lint
|
||||||
|
- plugin-ci/test
|
||||||
|
- plugin-ci/build
|
||||||
27
.editorconfig
Normal file
27
.editorconfig
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# http://editorconfig.org/
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
end_of_line = lf
|
||||||
|
insert_final_newline = true
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.{js, jsx, ts, tsx, json, html}]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[webapp/package.json]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[{Makefile, *.mk}]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
trim_trailing_whitespace = false
|
||||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
server/manifest.go linguist-generated=true
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
dist/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# Mac
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# VSCode
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# Jetbrains
|
||||||
|
.idea/
|
||||||
1
.gitpod.yml
Normal file
1
.gitpod.yml
Normal file
@ -0,0 +1 @@
|
|||||||
|
mainConfiguration: https://github.com/mattermost/mattermost-gitpod-config
|
||||||
56
.golangci.yml
Normal file
56
.golangci.yml
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
modules-download-mode: readonly
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
gofmt:
|
||||||
|
simplify: true
|
||||||
|
goimports:
|
||||||
|
local-prefixes: github.com/mattermost/mattermost-starter-template
|
||||||
|
golint:
|
||||||
|
min-confidence: 0
|
||||||
|
govet:
|
||||||
|
check-shadowing: true
|
||||||
|
enable-all: true
|
||||||
|
misspell:
|
||||||
|
locale: US
|
||||||
|
|
||||||
|
linters:
|
||||||
|
disable-all: true
|
||||||
|
enable:
|
||||||
|
- bodyclose
|
||||||
|
- deadcode
|
||||||
|
- errcheck
|
||||||
|
- gocritic
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
|
- golint
|
||||||
|
- gosec
|
||||||
|
- gosimple
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- misspell
|
||||||
|
- nakedret
|
||||||
|
- staticcheck
|
||||||
|
- structcheck
|
||||||
|
- stylecheck
|
||||||
|
- typecheck
|
||||||
|
- unconvert
|
||||||
|
- unused
|
||||||
|
- varcheck
|
||||||
|
- whitespace
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-rules:
|
||||||
|
- path: server/manifest.go
|
||||||
|
linters:
|
||||||
|
- deadcode
|
||||||
|
- unused
|
||||||
|
- varcheck
|
||||||
|
- path: server/configuration.go
|
||||||
|
linters:
|
||||||
|
- unused
|
||||||
|
- path: _test\.go
|
||||||
|
linters:
|
||||||
|
- bodyclose
|
||||||
|
- scopelint # https://github.com/kyoh86/scopelint/issues/4
|
||||||
201
LICENSE
Normal file
201
LICENSE
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
382
Makefile
Normal file
382
Makefile
Normal file
@ -0,0 +1,382 @@
|
|||||||
|
GO ?= $(shell command -v go 2> /dev/null)
|
||||||
|
NPM ?= $(shell command -v yarn 2> /dev/null)
|
||||||
|
CURL ?= $(shell command -v curl 2> /dev/null)
|
||||||
|
MM_DEBUG ?=
|
||||||
|
MANIFEST_FILE ?= plugin.json
|
||||||
|
GOPATH ?= $(shell go env GOPATH)
|
||||||
|
GO_TEST_FLAGS ?= -race
|
||||||
|
GO_BUILD_FLAGS ?=
|
||||||
|
MM_UTILITIES_DIR ?= ../mattermost-utilities
|
||||||
|
DLV_DEBUG_PORT := 2346
|
||||||
|
DEFAULT_GOOS := $(shell go env GOOS)
|
||||||
|
DEFAULT_GOARCH := $(shell go env GOARCH)
|
||||||
|
SANS_RESTAPITEST = $(shell go list ./... | grep -v 'git.wilix.dev/loop/loop-plugin-apps/test/restapitest' | sed -e 's/git.wilix.dev\/loop\/loop-plugin-apps\//.\//g')
|
||||||
|
|
||||||
|
ifneq (,$(wildcard ./.env))
|
||||||
|
include .env
|
||||||
|
export $(shell sed 's/=.*//' .env)
|
||||||
|
endif
|
||||||
|
|
||||||
|
export GO111MODULE=on
|
||||||
|
|
||||||
|
# You can include assets this directory into the bundle. This can be e.g. used to include profile pictures.
|
||||||
|
ASSETS_DIR ?= assets
|
||||||
|
|
||||||
|
## Define the default target (make all)
|
||||||
|
.PHONY: default
|
||||||
|
default: all
|
||||||
|
|
||||||
|
# Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed.
|
||||||
|
include build/setup.mk
|
||||||
|
|
||||||
|
BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz
|
||||||
|
|
||||||
|
# Include custom makefile, if present
|
||||||
|
ifneq ($(wildcard build/custom.mk),)
|
||||||
|
include build/custom.mk
|
||||||
|
endif
|
||||||
|
|
||||||
|
# ====================================================================================
|
||||||
|
# Used for semver bumping
|
||||||
|
PROTECTED_BRANCH := master
|
||||||
|
APP_NAME := $(shell basename -s .git `git config --get remote.origin.url`)
|
||||||
|
CURRENT_VERSION := $(shell git describe --abbrev=0 --tags)
|
||||||
|
VERSION_PARTS := $(subst ., ,$(subst v,,$(subst -rc, ,$(CURRENT_VERSION))))
|
||||||
|
MAJOR := $(word 1,$(VERSION_PARTS))
|
||||||
|
MINOR := $(word 2,$(VERSION_PARTS))
|
||||||
|
PATCH := $(word 3,$(VERSION_PARTS))
|
||||||
|
RC := $(shell echo $(CURRENT_VERSION) | grep -oE 'rc[0-9]+' | sed 's/rc//')
|
||||||
|
# Check if current branch is protected
|
||||||
|
define check_protected_branch
|
||||||
|
@current_branch=$$(git rev-parse --abbrev-ref HEAD); \
|
||||||
|
if ! echo "$(PROTECTED_BRANCH)" | grep -wq "$$current_branch" && ! echo "$$current_branch" | grep -q "^release"; then \
|
||||||
|
echo "Error: Tagging is only allowed from $(PROTECTED_BRANCH) or release branches. You are on $$current_branch branch."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
endef
|
||||||
|
# Check if there are pending pulls
|
||||||
|
define check_pending_pulls
|
||||||
|
@git fetch; \
|
||||||
|
current_branch=$$(git rev-parse --abbrev-ref HEAD); \
|
||||||
|
if [ "$$(git rev-parse HEAD)" != "$$(git rev-parse origin/$$current_branch)" ]; then \
|
||||||
|
echo "Error: Your branch is not up to date with upstream. Please pull the latest changes before performing a release"; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
endef
|
||||||
|
# Prompt for approval
|
||||||
|
define prompt_approval
|
||||||
|
@read -p "About to bump $(APP_NAME) to version $(1), approve? (y/n) " userinput; \
|
||||||
|
if [ "$$userinput" != "y" ]; then \
|
||||||
|
echo "Bump aborted."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
endef
|
||||||
|
# ====================================================================================
|
||||||
|
|
||||||
|
.PHONY: patch minor major patch-rc minor-rc major-rc
|
||||||
|
|
||||||
|
patch: ## to bump patch version (semver)
|
||||||
|
$(call check_protected_branch)
|
||||||
|
$(call check_pending_pulls)
|
||||||
|
@$(eval PATCH := $(shell echo $$(($(PATCH)+1))))
|
||||||
|
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH))
|
||||||
|
@echo Bumping $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH)
|
||||||
|
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH)"
|
||||||
|
git push origin v$(MAJOR).$(MINOR).$(PATCH)
|
||||||
|
@echo Bumped $(APP_NAME) to Patch version $(MAJOR).$(MINOR).$(PATCH)
|
||||||
|
|
||||||
|
minor: ## to bump minor version (semver)
|
||||||
|
$(call check_protected_branch)
|
||||||
|
$(call check_pending_pulls)
|
||||||
|
@$(eval MINOR := $(shell echo $$(($(MINOR)+1))))
|
||||||
|
@$(eval PATCH := 0)
|
||||||
|
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH))
|
||||||
|
@echo Bumping $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH)
|
||||||
|
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH)"
|
||||||
|
git push origin v$(MAJOR).$(MINOR).$(PATCH)
|
||||||
|
@echo Bumped $(APP_NAME) to Minor version $(MAJOR).$(MINOR).$(PATCH)
|
||||||
|
|
||||||
|
major: ## to bump major version (semver)
|
||||||
|
$(call check_protected_branch)
|
||||||
|
$(call check_pending_pulls)
|
||||||
|
$(eval MAJOR := $(shell echo $$(($(MAJOR)+1))))
|
||||||
|
$(eval MINOR := 0)
|
||||||
|
$(eval PATCH := 0)
|
||||||
|
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH))
|
||||||
|
@echo Bumping $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH)
|
||||||
|
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH) -m "Bumping $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH)"
|
||||||
|
git push origin v$(MAJOR).$(MINOR).$(PATCH)
|
||||||
|
@echo Bumped $(APP_NAME) to Major version $(MAJOR).$(MINOR).$(PATCH)
|
||||||
|
|
||||||
|
patch-rc: ## to bump patch release candidate version (semver)
|
||||||
|
$(call check_protected_branch)
|
||||||
|
$(call check_pending_pulls)
|
||||||
|
@$(eval RC := $(shell echo $$(($(RC)+1))))
|
||||||
|
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC))
|
||||||
|
@echo Bumping $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||||
|
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)"
|
||||||
|
git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||||
|
@echo Bumped $(APP_NAME) to Patch RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||||
|
|
||||||
|
minor-rc: ## to bump minor release candidate version (semver)
|
||||||
|
$(call check_protected_branch)
|
||||||
|
$(call check_pending_pulls)
|
||||||
|
@$(eval MINOR := $(shell echo $$(($(MINOR)+1))))
|
||||||
|
@$(eval PATCH := 0)
|
||||||
|
@$(eval RC := 1)
|
||||||
|
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC))
|
||||||
|
@echo Bumping $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||||
|
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)"
|
||||||
|
git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||||
|
@echo Bumped $(APP_NAME) to Minor RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||||
|
|
||||||
|
major-rc: ## to bump major release candidate version (semver)
|
||||||
|
$(call check_protected_branch)
|
||||||
|
$(call check_pending_pulls)
|
||||||
|
@$(eval MAJOR := $(shell echo $$(($(MAJOR)+1))))
|
||||||
|
@$(eval MINOR := 0)
|
||||||
|
@$(eval PATCH := 0)
|
||||||
|
@$(eval RC := 1)
|
||||||
|
$(call prompt_approval,$(MAJOR).$(MINOR).$(PATCH)-rc$(RC))
|
||||||
|
@echo Bumping $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||||
|
git tag -s -a v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC) -m "Bumping $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)"
|
||||||
|
git push origin v$(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||||
|
@echo Bumped $(APP_NAME) to Major RC version $(MAJOR).$(MINOR).$(PATCH)-rc$(RC)
|
||||||
|
|
||||||
|
## Checks the code style, tests, builds and bundles the plugin.
|
||||||
|
.PHONY: all
|
||||||
|
all: check-style test dist
|
||||||
|
|
||||||
|
.PHONY: deps
|
||||||
|
deps:
|
||||||
|
@$(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.51.2
|
||||||
|
@$(GO) install github.com/nicksnyder/go-i18n/v2/goi18n@v2.2.0
|
||||||
|
|
||||||
|
## Runs eslint and golangci-lint
|
||||||
|
.PHONY: check-style
|
||||||
|
check-style: webapp/node_modules
|
||||||
|
@echo Checking for style guide compliance
|
||||||
|
|
||||||
|
ifneq ($(HAS_WEBAPP),)
|
||||||
|
cd webapp && npm run lint
|
||||||
|
cd webapp && npm run check-types
|
||||||
|
endif
|
||||||
|
|
||||||
|
ifneq ($(HAS_SERVER),)
|
||||||
|
@echo Running golangci-lint
|
||||||
|
golangci-lint run ./... --verbose
|
||||||
|
endif
|
||||||
|
|
||||||
|
## Builds the server, if it exists, for all supported architectures, unless MM_SERVICESETTINGS_ENABLEDEVELOPER is set
|
||||||
|
.PHONY: server
|
||||||
|
server:
|
||||||
|
ifneq ($(HAS_SERVER),)
|
||||||
|
mkdir -p server/dist;
|
||||||
|
ifeq ($(MM_DEBUG),)
|
||||||
|
ifneq ($(MM_SERVICESETTINGS_ENABLEDEVELOPER),)
|
||||||
|
cd server && env CGO_ENABLED=0 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-$(DEFAULT_GOOS)-$(DEFAULT_GOARCH);
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-linux-amd64;
|
||||||
|
else
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-linux-amd64;
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-linux-arm64;
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-darwin-amd64;
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-darwin-arm64;
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-windows-amd64.exe;
|
||||||
|
endif
|
||||||
|
else
|
||||||
|
$(info DEBUG mode is on; to disable, unset MM_DEBUG)
|
||||||
|
ifneq ($(MM_SERVICESETTINGS_ENABLEDEVELOPER),)
|
||||||
|
cd server && env CGO_ENABLED=0 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-$(DEFAULT_GOOS)-$(DEFAULT_GOARCH);
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-linux-amd64;
|
||||||
|
else
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-linux-amd64;
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-linux-arm64;
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-darwin-amd64;
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-darwin-arm64;
|
||||||
|
cd server && env CGO_ENABLED=0 GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-windows-amd64.exe;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
## Ensures NPM dependencies are installed without having to run this all the time.
|
||||||
|
webapp/node_modules: $(wildcard webapp/package.json)
|
||||||
|
ifneq ($(HAS_WEBAPP),)
|
||||||
|
cd webapp && $(NPM) install
|
||||||
|
touch $@
|
||||||
|
endif
|
||||||
|
|
||||||
|
## Builds the webapp, if it exists.
|
||||||
|
.PHONY: webapp
|
||||||
|
webapp: webapp/node_modules
|
||||||
|
ifneq ($(HAS_WEBAPP),)
|
||||||
|
ifeq ($(MM_DEBUG),)
|
||||||
|
cd webapp && $(NPM) build;
|
||||||
|
else
|
||||||
|
cd webapp && $(NPM) debug;
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
## Generates a tar bundle of the plugin for install.
|
||||||
|
.PHONY: bundle
|
||||||
|
bundle:
|
||||||
|
rm -rf dist/
|
||||||
|
mkdir -p dist/$(PLUGIN_ID)
|
||||||
|
cp $(MANIFEST_FILE) dist/$(PLUGIN_ID)/
|
||||||
|
ifneq ($(wildcard $(ASSETS_DIR)/.),)
|
||||||
|
cp -r $(ASSETS_DIR) dist/$(PLUGIN_ID)/
|
||||||
|
endif
|
||||||
|
ifneq ($(HAS_PUBLIC),)
|
||||||
|
cp -r public dist/$(PLUGIN_ID)/
|
||||||
|
endif
|
||||||
|
ifneq ($(HAS_SERVER),)
|
||||||
|
mkdir -p dist/$(PLUGIN_ID)/server
|
||||||
|
cp -r server/dist dist/$(PLUGIN_ID)/server/
|
||||||
|
endif
|
||||||
|
ifneq ($(HAS_WEBAPP),)
|
||||||
|
mkdir -p dist/$(PLUGIN_ID)/webapp
|
||||||
|
cp -r webapp/dist dist/$(PLUGIN_ID)/webapp/
|
||||||
|
endif
|
||||||
|
cd dist && \
|
||||||
|
find ./ -type f \( -name ".DS_Store" -o -name "._*" -o -name ".Trashes" -o -name ".Spotlight-*" -o -name ".fseventsd" \) -delete && \
|
||||||
|
tar -cvzf $(BUNDLE_NAME) $(PLUGIN_ID)
|
||||||
|
|
||||||
|
@echo plugin built at: dist/$(BUNDLE_NAME)
|
||||||
|
|
||||||
|
## Builds and bundles the plugin.
|
||||||
|
.PHONY: dist
|
||||||
|
dist: server webapp bundle
|
||||||
|
|
||||||
|
## Builds and installs the plugin to a server.
|
||||||
|
.PHONY: deploy
|
||||||
|
deploy: dist
|
||||||
|
./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME)
|
||||||
|
|
||||||
|
## Builds and installs the plugin to a server, updating the webapp automatically when changed.
|
||||||
|
.PHONY: watch
|
||||||
|
watch: apply server bundle
|
||||||
|
ifeq ($(MM_DEBUG),)
|
||||||
|
cd webapp && $(NPM) run build:watch
|
||||||
|
else
|
||||||
|
cd webapp && $(NPM) run debug:watch
|
||||||
|
endif
|
||||||
|
|
||||||
|
## Installs a previous built plugin with updated webpack assets to a server.
|
||||||
|
.PHONY: deploy-from-watch
|
||||||
|
deploy-from-watch: bundle
|
||||||
|
./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME)
|
||||||
|
|
||||||
|
## Setup dlv for attaching, identifying the plugin PID for other targets.
|
||||||
|
.PHONY: setup-attach
|
||||||
|
setup-attach:
|
||||||
|
$(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}'))
|
||||||
|
$(eval NUM_PID := $(shell echo -n ${PLUGIN_PID} | wc -w))
|
||||||
|
|
||||||
|
@if [ ${NUM_PID} -gt 2 ]; then \
|
||||||
|
echo "** There is more than 1 plugin process running. Run 'make kill reset' to restart just one."; \
|
||||||
|
exit 1; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
## Check if setup-attach succeeded.
|
||||||
|
.PHONY: check-attach
|
||||||
|
check-attach:
|
||||||
|
@if [ -z ${PLUGIN_PID} ]; then \
|
||||||
|
echo "Could not find plugin PID; the plugin is not running. Exiting."; \
|
||||||
|
exit 1; \
|
||||||
|
else \
|
||||||
|
echo "Located Plugin running with PID: ${PLUGIN_PID}"; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
## Attach dlv to an existing plugin instance.
|
||||||
|
.PHONY: attach
|
||||||
|
attach: setup-attach check-attach
|
||||||
|
dlv attach ${PLUGIN_PID}
|
||||||
|
|
||||||
|
## Attach dlv to an existing plugin instance, exposing a headless instance on $DLV_DEBUG_PORT.
|
||||||
|
.PHONY: attach-headless
|
||||||
|
attach-headless: setup-attach check-attach
|
||||||
|
dlv attach ${PLUGIN_PID} --listen :$(DLV_DEBUG_PORT) --headless=true --api-version=2 --accept-multiclient
|
||||||
|
|
||||||
|
## Detach dlv from an existing plugin instance, if previously attached.
|
||||||
|
.PHONY: detach
|
||||||
|
detach: setup-attach
|
||||||
|
@DELVE_PID=$(shell ps aux | grep "dlv attach ${PLUGIN_PID}" | grep -v "grep" | awk -F " " '{print $$2}') && \
|
||||||
|
if [ "$$DELVE_PID" -gt 0 ] > /dev/null 2>&1 ; then \
|
||||||
|
echo "Located existing delve process running with PID: $$DELVE_PID. Killing." ; \
|
||||||
|
kill -9 $$DELVE_PID ; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
## Runs any lints and unit tests defined for the server and webapp, if they exist.
|
||||||
|
.PHONY: test
|
||||||
|
test: webapp/node_modules
|
||||||
|
ifneq ($(HAS_SERVER),)
|
||||||
|
$(GO) test -v $(GO_TEST_FLAGS) $(SANS_RESTAPITEST)
|
||||||
|
endif
|
||||||
|
ifneq ($(HAS_WEBAPP),)
|
||||||
|
cd webapp && $(NPM) run test;
|
||||||
|
endif
|
||||||
|
|
||||||
|
## Creates a coverage report for the server code.
|
||||||
|
.PHONY: coverage
|
||||||
|
coverage: webapp/node_modules
|
||||||
|
ifneq ($(HAS_SERVER),)
|
||||||
|
$(GO) test $(GO_TEST_FLAGS) -coverprofile=server/coverage.txt $(SANS_RESTAPITEST)
|
||||||
|
$(GO) tool cover -html=server/coverage.txt
|
||||||
|
endif
|
||||||
|
|
||||||
|
## Extract strings for translation from the source code.
|
||||||
|
.PHONY: i18n-extract
|
||||||
|
i18n-extract:
|
||||||
|
ifneq ($(HAS_WEBAPP),)
|
||||||
|
ifeq ($(HAS_MM_UTILITIES),)
|
||||||
|
@echo "You must clone github.com/mattermost/mattermost-utilities repo in .. to use this command"
|
||||||
|
else
|
||||||
|
cd $(MM_UTILITIES_DIR) && npm install && npm run babel && node mmjstool/build/index.js i18n extract-webapp --webapp-dir $(PWD)/webapp
|
||||||
|
endif
|
||||||
|
endif
|
||||||
|
|
||||||
|
## Disable the plugin.
|
||||||
|
.PHONY: disable
|
||||||
|
disable: detach
|
||||||
|
./build/bin/pluginctl disable $(PLUGIN_ID)
|
||||||
|
|
||||||
|
## Enable the plugin.
|
||||||
|
.PHONY: enable
|
||||||
|
enable:
|
||||||
|
./build/bin/pluginctl enable $(PLUGIN_ID)
|
||||||
|
|
||||||
|
## Reset the plugin, effectively disabling and re-enabling it on the server.
|
||||||
|
.PHONY: reset
|
||||||
|
reset: detach
|
||||||
|
./build/bin/pluginctl reset $(PLUGIN_ID)
|
||||||
|
|
||||||
|
## Kill all instances of the plugin, detaching any existing dlv instance.
|
||||||
|
.PHONY: kill
|
||||||
|
kill: detach
|
||||||
|
$(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}'))
|
||||||
|
|
||||||
|
@for PID in ${PLUGIN_PID}; do \
|
||||||
|
echo "Killing plugin pid $$PID"; \
|
||||||
|
kill -9 $$PID; \
|
||||||
|
done; \
|
||||||
|
|
||||||
|
## Clean removes all build artifacts.
|
||||||
|
.PHONY: clean
|
||||||
|
clean:
|
||||||
|
rm -fr dist/
|
||||||
|
ifneq ($(HAS_SERVER),)
|
||||||
|
rm -fr server/coverage.txt
|
||||||
|
rm -fr server/dist
|
||||||
|
endif
|
||||||
|
ifneq ($(HAS_WEBAPP),)
|
||||||
|
rm -fr webapp/junit.xml
|
||||||
|
rm -fr webapp/dist
|
||||||
|
rm -fr webapp/node_modules
|
||||||
|
endif
|
||||||
|
rm -fr build/bin/
|
||||||
|
rm -fr test/restapitest/mattermost.log
|
||||||
|
rm -fr test/restapitest/notifications.log
|
||||||
|
rm -fr test/restapitest/data/
|
||||||
|
|
||||||
|
# Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html
|
||||||
|
help:
|
||||||
|
@cat Makefile build/*.mk | grep -v '\.PHONY' | grep -v '\help:' | grep -B1 -E '^[a-zA-Z0-9_.-]+:.*' | sed -e "s/:.*//" | sed -e "s/^## //" | grep -v '\-\-' | sed '1!G;h;$$!d' | awk 'NR%2{printf "\033[36m%-30s\033[0m",$$0;next;}1' | sort
|
||||||
175
README.md
Normal file
175
README.md
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
# Badges plugin
|
||||||
|
Let your users show appreciation for their fellow colleagues by granting badges.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
Get the latest release from [GitHub](https://github.com/larkox/mattermost-plugin-badges/releases) and [install it manually](https://developers.mattermost.com/integrate/plugins/server/hello-world/#installing-the-plugin) on your server.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Badges admin**: Every System Admin is considered a badges admin. System Admins can assign the badges admin role to a single person by specifying their username. Only a single badge admin assignment is permitted.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
### Creating a type
|
||||||
|
Badge admins can create different types of badges, and each type of badge can have its own permissions. You must be a badge admin to create a badge type.
|
||||||
|
Run the slash command `/badges create type` to open the creation dialog.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Name**: The type of badge that's visible in the badges description.
|
||||||
|
- **Everyone can create badge**: If you mark this checkbox, every user in your Mattermost instance can create badges of this type.
|
||||||
|
- **Can create allowlist**: This list contains the usernames (comma separated) of all the people allowed to create badges of this type.
|
||||||
|
- **Everyone can grant badge**: If you mark this checkbox, every user in your Mattermost instance can grant any badge of this type.
|
||||||
|
- **Can grant allowlist**: This list contains the usernames (comma separated) of all the people allowed to grant badges of this type.
|
||||||
|
|
||||||
|
### Permissions details
|
||||||
|
Badge admins can always create types, create badges for any type, and grant badges from any type, regardless of the permissions in place for a given badge type.
|
||||||
|
A badge creator can always grant the badge they created.
|
||||||
|
Any other user is subject to the permissions defined as part of the badge type.
|
||||||
|
|
||||||
|
Some examples of badge permissions by type are included below. Remember that badge admins have full control over badges, and badge creators can always grant badges. The examples below are intended to demonstrate how badge permissions can be configured for non-admin users to get the most out of badges.
|
||||||
|
(ECC: Everyone Can Create, CC: Can Create Allowlist, ECG: Everyone Can Grant, CG: Can Grant Allowlist)
|
||||||
|
| Permissions | Example | ECC | ECG | CC | CG |
|
||||||
|
|----------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|-------|-------|--------------|--------------|
|
||||||
|
| Only badge admins can create and grant badges | | false | false | empty | empty |
|
||||||
|
| Only user1 can create badges, but everyone can grant them | for peer appreciation badges, like "Thank you" badge | false | true | user1 | empty |
|
||||||
|
| Only user1 can create badges, and only user2 and user3 can grant them | lead appreciation badges, like "MVP" badge, where the management create the badges, and the team leads are the ones granting them to their team members | false | false | user1 | user2, user3 |
|
||||||
|
| Only user1 and user2 can create badges, but they can only grant the badges they have created | can be used to have team specific badges without creating a new type for every team | false | false | user1, user2 | empty |
|
||||||
|
| Everyone can create badges, but can only grant the badges they have created | | true | false | empty | empty |
|
||||||
|
| Everyone can create and grant any badge | | true | true | empty | empty |
|
||||||
|
|
||||||
|
### Creating a badge
|
||||||
|
Run the slash command `/badges create badge` to open the creation dialog.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Name**: Name of the badge.
|
||||||
|
- **Description**: Description of the badge.
|
||||||
|
- **Image**: Only emojis are allowed. You must input the emoji name as you would to add it to a message (e.g. `:+1:` or `:smile:`). Custom emojis are also allowed.
|
||||||
|
- **Type**: The type of badge. This list will show only types you have permissions to create.
|
||||||
|
- **Multiple**: Whether this badge can be granted more than once to the same person.
|
||||||
|
|
||||||
|
### Details about Multiple
|
||||||
|
All badges can be assigned to any number of people. What the **Multiple** setting controls is whether this badge can be granted more than once to the same person. For example, a "Thank you" badge should be grantable many times (many people can be thankful to you on more than one occasion), and therefore, a Thank You badge should have the **Multiple** option selected. However, a "First year in the company" badge should be granted only once since a user won't celebrate this milestone multiple times at the same company. This type of badge should have the **Multiple** option unselected.
|
||||||
|
|
||||||
|
### Granting a badge
|
||||||
|
There are two ways to open the grant dialog:
|
||||||
|
- Run the `/badges grant` command.
|
||||||
|
- Click on the **Grant badge** link available in the Profile Popover, visible when you click on someone's username.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The dialog looks like this:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **User**: The user you want to grant the badge to (may be prepopulated if you clicked the grant button from the profile popover, or added the username in the command).
|
||||||
|
- **Badge**: The badge you want to grant (may be prepopulated if you added the badge id in the command).
|
||||||
|
- **Reason**: An optional reason why you are awarding this badge. (Specially useful for badges like "Thank you").
|
||||||
|
- **Notify on this channel**: If you select this option, a message from the badges bot will be posted in the current channel, letting everyone in that channel know that you granted this badge to that person.
|
||||||
|
The user that received the badge will always receive a DM from the badges bot letting them know they have been awarded a badge. In addition, the following may happen:
|
||||||
|
- If **Notify on this channel** was marked, the badges bot will post a message on the current channel letting everyone know that the user has been awarded a badge.
|
||||||
|
- If a subscription for this badge type is set, the badges bot will post a message on all subscribed channels letting everyone know that the user has been awarded a badge.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
If you try to award a badge that can't be awarded more than once to a single recipient, the badge won't be granted.
|
||||||
|
|
||||||
|
### Subscriptions
|
||||||
|
In order to create a subscription, you must be a badges admin.
|
||||||
|
Subscriptions will create posts into a channel every time a badge is granted. There is no limit to the number of subscriptions per channel or per type.
|
||||||
|
There are two ways to open the subscription creation dialog:
|
||||||
|
- Run the `/badges subscription create` command.
|
||||||
|
- Click on the **Add badge subscription** menu from the channel menu.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The dialog looks like this:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
- **Type**: The type of badges you want to subscribe to this channel.
|
||||||
|
|
||||||
|
In order to remove subscriptions, a similar dialog can be opened by using the `/badges subscription remove` and the **Remove badge subscription** option from the channel menu.
|
||||||
|
|
||||||
|
### Editing a deleting badges and types
|
||||||
|
In order to edit or delete types you must be a badge admin. In order to edit or delete a badge, you must be a badge admin or the creator.
|
||||||
|
Run `/badges edit type --type typeID` or `/badges edit badge --id badgeID` to open a dialog pretty similar to the creation dialog. IDs are not human readable, but Autocomplete will help you select the right badge.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
The only difference to the creation is one extra checkbox to remove the current type or badge. If you mark this checkbox and click **Edit**, the badge or type will be removed.
|
||||||
|
When you remove a badge, the badge is deleted permanently, along with any information about who that badge was granted to. When you remove a type, the type and all the associated badges are removed completely.
|
||||||
|
|
||||||
|
### Badge list
|
||||||
|
Badges show on several places. On the profile popover of the users, they show up to the last 20 badges granted to that user. Hovering over the badges will give you more information, and cliking on them will open the Right Hand Sidebar (RHS) with the badge details.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
The channel header button will open the RHS with the list of all badges.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
Clicking on any badge will lead you to the badge details. Here you can check all the users that have been granted this badge.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Clicking on any username on the badge details screen will lead you to the badges granted to that user.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Using the Plugin API to create and grant badges
|
||||||
|
This plugin can be integrated with any other plugin in your system, to automatize the creation and granting of badges.
|
||||||
|
|
||||||
|
Using the [PluginHTTP](https://developers.mattermost.com/integrate/plugins/server/reference/#API.PluginHTTP) API method, you can create a request to the badges plugin to "Ensure" and to "Grant" the badges needed.
|
||||||
|
|
||||||
|
The badges plugin exposes the `badgesmodel` package to simplify handling several parts of this process. Some important exposed objects:
|
||||||
|
- badgesmodel.PluginPath (`/com.mattermost.badges`): The base URL for the plugin (the plugin id).
|
||||||
|
- badgesmodel.PluginAPIPath (`/papi/v1`): The plugin api route.
|
||||||
|
- badgesmodel.PluginAPIPathEnsure (`/ensure`): The ensure endpoint route.
|
||||||
|
- badgesmodel.PluginAPIPathGrant (`/grant`): The grant endpoint route.
|
||||||
|
- badgesmodel.Badge: The data model for badges.
|
||||||
|
- badgesmodel.EnsureBadgesRequest: The data model of the body of a Ensure Badges Request.
|
||||||
|
- badgesmodel.GrantBadgeRequest: The data model of the body of a Grant Badge Request.
|
||||||
|
- badgesmodel.ImageTypeEmoj (`emoji`): The emoji image type. Other image types are considered, but we recommend using emojis.
|
||||||
|
|
||||||
|
### Ensure badges
|
||||||
|
URL: `/com.mattermost.badges/papi/v1/ensure`
|
||||||
|
|
||||||
|
Method: `POST`
|
||||||
|
|
||||||
|
Body example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Badges":[
|
||||||
|
{
|
||||||
|
"name":"My badge",
|
||||||
|
"description":"Awesome badge",
|
||||||
|
"image":"smile",
|
||||||
|
"image_type":"emoji",
|
||||||
|
"multiple":true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"BotId":"myBotId"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Ensure badges will create badges if they already do not exist, and return the list of badges including the ids. In order to check whether a badge exist or not, it will only check the name of the badge.
|
||||||
|
|
||||||
|
### Grant badges
|
||||||
|
URL: `/com.mattermost.badges/papi/v1/grant`
|
||||||
|
|
||||||
|
Method: `POST`
|
||||||
|
|
||||||
|
Body example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"BadgeID":"badgeID",
|
||||||
|
"BotId":"myBotId",
|
||||||
|
"UserID":"userID",
|
||||||
|
"Reason":""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Grant badges will grant the badge with the badge id provided from the bot to the user defined. Reason is optional.
|
||||||
0
assets/.gitkeep
Normal file
0
assets/.gitkeep
Normal file
14
assets/starter-template-icon.svg
Normal file
14
assets/starter-template-icon.svg
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg width="241px" height="240px" viewBox="0 0 241 240" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||||
|
<!-- Generator: Sketch 46.2 (44496) - http://www.bohemiancoding.com/sketch -->
|
||||||
|
<title>blue-icon</title>
|
||||||
|
<desc>Created with Sketch.</desc>
|
||||||
|
<defs></defs>
|
||||||
|
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||||
|
<g id="06" transform="translate(-681.000000, -572.000000)" fill="#1875F0">
|
||||||
|
<g id="Group-2" transform="translate(626.000000, 517.000000)">
|
||||||
|
<path d="M216.908181,153.127705 C216.908181,153.127705 217.280588,169.452526 205.928754,180.543035 C194.57546,191.633544 180.631383,190.619887 171.560722,187.557072 C162.488602,184.494256 150.79503,176.85251 148.531381,161.16705 C146.269193,145.480133 156.508188,132.736607 156.508188,132.736607 L178.820463,105.066407 L191.815268,89.2629779 L202.969946,75.4912313 C202.969946,75.4912313 208.088713,68.6534193 209.547671,67.2421648 C209.836834,66.9625354 210.133299,66.7790286 210.423923,66.6377576 L210.635683,66.5299837 L210.673654,66.5154197 C211.28703,66.2518108 211.993873,66.195011 212.675888,66.4251227 C213.343299,66.6508652 213.860288,67.1081757 214.187421,67.6718037 L214.256061,67.7810339 L214.315938,67.9062846 C214.475124,68.2063036 214.608022,68.5485583 214.67082,68.9709151 C214.968745,70.976382 214.870897,79.5094471 214.870897,79.5094471 L215.342613,97.2047434 L216.039232,117.630795 L216.908181,153.127705 Z M245.790587,78.2043261 C287.057212,108.155253 305.982915,162.509669 288.774288,213.346872 C267.594104,275.911031 199.706245,309.46073 137.142925,288.281718 C74.5796048,267.10125 41.031812,199.213937 62.2105402,136.649778 C79.4482947,85.7295603 127.625459,54.0324057 178.690632,55.4145322 L162.322339,74.7541074 C132.028106,80.231639 105.87146,100.919843 95.5908489,131.290215 C80.2944535,176.475117 105.932628,225.982624 152.855846,241.866155 C199.777608,257.751142 250.216536,233.998666 265.512932,188.813764 C275.760046,158.543884 267.634882,126.336988 247.050359,103.595256 L245.790587,78.2043261 Z" id="blue-icon"></path>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
15
badgesmodel/constants.go
Normal file
15
badgesmodel/constants.go
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package badgesmodel
|
||||||
|
|
||||||
|
const (
|
||||||
|
NameMaxLength = 20
|
||||||
|
DescriptionMaxLength = 120
|
||||||
|
|
||||||
|
ImageTypeEmoji ImageType = "emoji"
|
||||||
|
ImageTypeRelativeURL ImageType = "rel_url"
|
||||||
|
ImageTypeAbsoluteURL ImageType = "abs_url"
|
||||||
|
|
||||||
|
PluginPath = "/com.mattermost.badges"
|
||||||
|
PluginAPIPath = "/papi/v1"
|
||||||
|
PluginAPIPathEnsure = "/ensure"
|
||||||
|
PluginAPIPathGrant = "/grant"
|
||||||
|
)
|
||||||
112
badgesmodel/model.go
Normal file
112
badgesmodel/model.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package badgesmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BadgeType string
|
||||||
|
type BadgeID string
|
||||||
|
|
||||||
|
type Ownership struct {
|
||||||
|
User string `json:"user"`
|
||||||
|
GrantedBy string `json:"granted_by"`
|
||||||
|
Badge BadgeID `json:"badge"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Time time.Time `json:"time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OwnershipList []Ownership
|
||||||
|
|
||||||
|
type Badge struct {
|
||||||
|
ID BadgeID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Image string `json:"image"`
|
||||||
|
ImageType ImageType `json:"image_type"`
|
||||||
|
Multiple bool `json:"multiple"`
|
||||||
|
Type BadgeType `json:"type"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserBadge struct {
|
||||||
|
Badge
|
||||||
|
Ownership
|
||||||
|
GrantedByUsername string `json:"granted_by_name"`
|
||||||
|
TypeName string `json:"type_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BadgeDetails struct {
|
||||||
|
Badge
|
||||||
|
Owners []Ownership `json:"owners"`
|
||||||
|
CreatedByUsername string `json:"created_by_username"`
|
||||||
|
TypeName string `json:"type_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AllBadgesBadge struct {
|
||||||
|
Badge
|
||||||
|
Granted int `json:"granted"`
|
||||||
|
GrantedTimes int `json:"granted_times"`
|
||||||
|
TypeName string `json:"type_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BadgeTypeDefinition struct {
|
||||||
|
ID BadgeType `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Frame string `json:"frame"`
|
||||||
|
CreatedBy string `json:"created_by"`
|
||||||
|
CanGrant PermissionScheme `json:"can_grant"`
|
||||||
|
CanCreate PermissionScheme `json:"can_create"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PermissionScheme struct {
|
||||||
|
Everyone bool `json:"everyone"`
|
||||||
|
Roles map[string]bool `json:"roles"`
|
||||||
|
AllowList map[string]bool `json:"allow_list"`
|
||||||
|
BlockList map[string]bool `json:"block_list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BadgeTypeList []*BadgeTypeDefinition
|
||||||
|
|
||||||
|
type ImageType string
|
||||||
|
|
||||||
|
type EnsureBadgesRequest struct {
|
||||||
|
Badges []*Badge
|
||||||
|
BotID string
|
||||||
|
}
|
||||||
|
|
||||||
|
type GrantBadgeRequest struct {
|
||||||
|
BadgeID BadgeID
|
||||||
|
UserID string
|
||||||
|
BotID string
|
||||||
|
Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Subscription struct {
|
||||||
|
TypeID BadgeType
|
||||||
|
ChannelID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b Badge) IsValid() bool {
|
||||||
|
return len(b.Name) <= NameMaxLength &&
|
||||||
|
len(b.Description) <= DescriptionMaxLength &&
|
||||||
|
b.Image != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l OwnershipList) IsOwned(user string, badge BadgeID) bool {
|
||||||
|
for _, ownership := range l {
|
||||||
|
if user == ownership.User && badge == ownership.Badge {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l BadgeTypeList) GetType(id BadgeType) *BadgeTypeDefinition {
|
||||||
|
for _, t := range l {
|
||||||
|
if t.ID == id {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
1
build/.gitignore
vendored
Normal file
1
build/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
bin
|
||||||
1
build/custom.mk
Normal file
1
build/custom.mk
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Include custom targets and environment variables here
|
||||||
11
build/go.mod
Normal file
11
build/go.mod
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
module github.com/larkox/mattermost-plugin-badges/build
|
||||||
|
|
||||||
|
go 1.12
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-git/go-git/v5 v5.1.0
|
||||||
|
github.com/mattermost/mattermost-server/v5 v5.3.2-0.20200924100636-e726b0426826
|
||||||
|
github.com/pkg/errors v0.9.1
|
||||||
|
github.com/stretchr/testify v1.6.1
|
||||||
|
sigs.k8s.io/yaml v1.2.0
|
||||||
|
)
|
||||||
1003
build/go.sum
Normal file
1003
build/go.sum
Normal file
File diff suppressed because it is too large
Load Diff
126
build/manifest/main.go
Normal file
126
build/manifest/main.go
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const pluginIDGoFileTemplate = `// This file is automatically generated. Do not modify it manually.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var manifest *model.Manifest
|
||||||
|
|
||||||
|
const manifestStr = ` + "`" + `
|
||||||
|
%s
|
||||||
|
` + "`" + `
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
manifest = model.ManifestFromJson(strings.NewReader(manifestStr))
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) <= 1 {
|
||||||
|
panic("no cmd specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := findManifest()
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to find manifest: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := os.Args[1]
|
||||||
|
switch cmd {
|
||||||
|
case "id":
|
||||||
|
dumpPluginID(manifest)
|
||||||
|
|
||||||
|
case "version":
|
||||||
|
dumpPluginVersion(manifest)
|
||||||
|
|
||||||
|
case "has_server":
|
||||||
|
if manifest.HasServer() {
|
||||||
|
fmt.Printf("true")
|
||||||
|
}
|
||||||
|
|
||||||
|
case "has_webapp":
|
||||||
|
if manifest.HasWebapp() {
|
||||||
|
fmt.Printf("true")
|
||||||
|
}
|
||||||
|
|
||||||
|
case "apply":
|
||||||
|
if err := applyManifest(manifest); err != nil {
|
||||||
|
panic("failed to apply manifest: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
panic("unrecognized command: " + cmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findManifest() (*model.Manifest, error) {
|
||||||
|
_, manifestFilePath, err := model.FindManifest(".")
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to find manifest in current working directory")
|
||||||
|
}
|
||||||
|
manifestFile, err := os.Open(manifestFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(err, "failed to open %s", manifestFilePath)
|
||||||
|
}
|
||||||
|
defer manifestFile.Close()
|
||||||
|
|
||||||
|
// Re-decode the manifest, disallowing unknown fields. When we write the manifest back out,
|
||||||
|
// we don't want to accidentally clobber anything we won't preserve.
|
||||||
|
var manifest model.Manifest
|
||||||
|
decoder := json.NewDecoder(manifestFile)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err = decoder.Decode(&manifest); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "failed to parse manifest")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &manifest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dumpPluginId writes the plugin id from the given manifest to standard out
|
||||||
|
func dumpPluginID(manifest *model.Manifest) {
|
||||||
|
fmt.Printf("%s", manifest.Id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dumpPluginVersion writes the plugin version from the given manifest to standard out
|
||||||
|
func dumpPluginVersion(manifest *model.Manifest) {
|
||||||
|
fmt.Printf("%s", manifest.Version)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyManifest propagates the plugin_id into the server and webapp folders, as necessary
|
||||||
|
func applyManifest(manifest *model.Manifest) error {
|
||||||
|
if manifest.HasServer() {
|
||||||
|
// generate JSON representation of Manifest.
|
||||||
|
manifestBytes, err := json.MarshalIndent(manifest, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
manifestStr := string(manifestBytes)
|
||||||
|
|
||||||
|
// write generated code to file by using Go file template.
|
||||||
|
if err := ioutil.WriteFile(
|
||||||
|
"server/manifest.go",
|
||||||
|
[]byte(fmt.Sprintf(pluginIDGoFileTemplate, manifestStr)),
|
||||||
|
0600,
|
||||||
|
); err != nil {
|
||||||
|
return errors.Wrap(err, "failed to write server/manifest.go")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
172
build/pluginctl/main.go
Normal file
172
build/pluginctl/main.go
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
// main handles deployment of the plugin to a development server using the Client4 API.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const helpText = `
|
||||||
|
Usage:
|
||||||
|
pluginctl deploy <plugin id> <bundle path>
|
||||||
|
pluginctl disable <plugin id>
|
||||||
|
pluginctl enable <plugin id>
|
||||||
|
pluginctl reset <plugin id>
|
||||||
|
`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
err := pluginctl()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Failed: %s\n", err.Error())
|
||||||
|
fmt.Print(helpText)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pluginctl() error {
|
||||||
|
if len(os.Args) < 3 {
|
||||||
|
return errors.New("invalid number of arguments")
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := getClient()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "deploy":
|
||||||
|
if len(os.Args) < 4 {
|
||||||
|
return errors.New("invalid number of arguments")
|
||||||
|
}
|
||||||
|
return deploy(client, os.Args[2], os.Args[3])
|
||||||
|
case "disable":
|
||||||
|
return disablePlugin(client, os.Args[2])
|
||||||
|
case "enable":
|
||||||
|
return enablePlugin(client, os.Args[2])
|
||||||
|
case "reset":
|
||||||
|
return resetPlugin(client, os.Args[2])
|
||||||
|
default:
|
||||||
|
return errors.New("invalid second argument")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getClient() (*model.Client4, error) {
|
||||||
|
socketPath := os.Getenv("MM_LOCALSOCKETPATH")
|
||||||
|
if socketPath == "" {
|
||||||
|
socketPath = model.LOCAL_MODE_SOCKET_PATH
|
||||||
|
}
|
||||||
|
|
||||||
|
client, connected := getUnixClient(socketPath)
|
||||||
|
if connected {
|
||||||
|
log.Printf("Connecting using local mode over %s", socketPath)
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if os.Getenv("MM_LOCALSOCKETPATH") != "" {
|
||||||
|
log.Printf("No socket found at %s for local mode deployment. Attempting to authenticate with credentials.", socketPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
siteURL := os.Getenv("MM_SERVICESETTINGS_SITEURL")
|
||||||
|
adminToken := os.Getenv("MM_ADMIN_TOKEN")
|
||||||
|
adminUsername := os.Getenv("MM_ADMIN_USERNAME")
|
||||||
|
adminPassword := os.Getenv("MM_ADMIN_PASSWORD")
|
||||||
|
|
||||||
|
if siteURL == "" {
|
||||||
|
return nil, errors.New("MM_SERVICESETTINGS_SITEURL is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
client = model.NewAPIv4Client(siteURL)
|
||||||
|
|
||||||
|
if adminToken != "" {
|
||||||
|
log.Printf("Authenticating using token against %s.", siteURL)
|
||||||
|
client.SetToken(adminToken)
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if adminUsername != "" && adminPassword != "" {
|
||||||
|
client := model.NewAPIv4Client(siteURL)
|
||||||
|
log.Printf("Authenticating as %s against %s.", adminUsername, siteURL)
|
||||||
|
_, resp := client.Login(adminUsername, adminPassword)
|
||||||
|
if resp.Error != nil {
|
||||||
|
return nil, fmt.Errorf("failed to login as %s: %w", adminUsername, resp.Error)
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("one of MM_ADMIN_TOKEN or MM_ADMIN_USERNAME/MM_ADMIN_PASSWORD must be defined")
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUnixClient(socketPath string) (*model.Client4, bool) {
|
||||||
|
_, err := net.Dial("unix", socketPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return model.NewAPIv4SocketClient(socketPath), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// deploy attempts to upload and enable a plugin via the Client4 API.
|
||||||
|
// It will fail if plugin uploads are disabled.
|
||||||
|
func deploy(client *model.Client4, pluginID, bundlePath string) error {
|
||||||
|
pluginBundle, err := os.Open(bundlePath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open %s: %w", bundlePath, err)
|
||||||
|
}
|
||||||
|
defer pluginBundle.Close()
|
||||||
|
|
||||||
|
log.Print("Uploading plugin via API.")
|
||||||
|
_, resp := client.UploadPluginForced(pluginBundle)
|
||||||
|
if resp.Error != nil {
|
||||||
|
return fmt.Errorf("failed to upload plugin bundle: %s", resp.Error.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Print("Enabling plugin.")
|
||||||
|
_, resp = client.EnablePlugin(pluginID)
|
||||||
|
if resp.Error != nil {
|
||||||
|
return fmt.Errorf("failed to enable plugin: %s", resp.Error.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// disablePlugin attempts to disable the plugin via the Client4 API.
|
||||||
|
func disablePlugin(client *model.Client4, pluginID string) error {
|
||||||
|
log.Print("Disabling plugin.")
|
||||||
|
_, resp := client.DisablePlugin(pluginID)
|
||||||
|
if resp.Error != nil {
|
||||||
|
return fmt.Errorf("failed to disable plugin: %w", resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// enablePlugin attempts to enable the plugin via the Client4 API.
|
||||||
|
func enablePlugin(client *model.Client4, pluginID string) error {
|
||||||
|
log.Print("Enabling plugin.")
|
||||||
|
_, resp := client.EnablePlugin(pluginID)
|
||||||
|
if resp.Error != nil {
|
||||||
|
return fmt.Errorf("failed to enable plugin: %w", resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resetPlugin attempts to reset the plugin via the Client4 API.
|
||||||
|
func resetPlugin(client *model.Client4, pluginID string) error {
|
||||||
|
err := disablePlugin(client, pluginID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = enablePlugin(client, pluginID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
45
build/setup.mk
Normal file
45
build/setup.mk
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
# Ensure that go is installed. Note that this is independent of whether or not a server is being
|
||||||
|
# built, since the build script itself uses go.
|
||||||
|
ifeq ($(GO),)
|
||||||
|
$(error "go is not available: see https://golang.org/doc/install")
|
||||||
|
endif
|
||||||
|
|
||||||
|
# Ensure that the build tools are compiled. Go's caching makes this quick.
|
||||||
|
$(shell cd build/manifest && $(GO) build -o ../bin/manifest)
|
||||||
|
|
||||||
|
# Ensure that the deployment tools are compiled. Go's caching makes this quick.
|
||||||
|
$(shell cd build/pluginctl && $(GO) build -o ../bin/pluginctl)
|
||||||
|
|
||||||
|
# Extract the plugin id from the manifest.
|
||||||
|
PLUGIN_ID ?= $(shell build/bin/manifest id)
|
||||||
|
ifeq ($(PLUGIN_ID),)
|
||||||
|
$(error "Cannot parse id from $(MANIFEST_FILE)")
|
||||||
|
endif
|
||||||
|
|
||||||
|
# Extract the plugin version from the manifest.
|
||||||
|
PLUGIN_VERSION ?= $(shell build/bin/manifest version)
|
||||||
|
ifeq ($(PLUGIN_VERSION),)
|
||||||
|
$(error "Cannot parse version from $(MANIFEST_FILE)")
|
||||||
|
endif
|
||||||
|
|
||||||
|
# Determine if a server is defined in the manifest.
|
||||||
|
HAS_SERVER ?= $(shell build/bin/manifest has_server)
|
||||||
|
|
||||||
|
# Determine if a webapp is defined in the manifest.
|
||||||
|
HAS_WEBAPP ?= $(shell build/bin/manifest has_webapp)
|
||||||
|
|
||||||
|
# Determine if a /public folder is in use
|
||||||
|
HAS_PUBLIC ?= $(wildcard public/.)
|
||||||
|
|
||||||
|
# Determine if the mattermost-utilities repo is present
|
||||||
|
HAS_MM_UTILITIES ?= $(wildcard $(MM_UTILITIES_DIR)/.)
|
||||||
|
|
||||||
|
# Store the current path for later use
|
||||||
|
PWD ?= $(shell pwd)
|
||||||
|
|
||||||
|
# Ensure that npm (and thus node) is installed.
|
||||||
|
ifneq ($(HAS_WEBAPP),)
|
||||||
|
ifeq ($(NPM),)
|
||||||
|
$(error "npm is not available: see https://www.npmjs.com/get-npm")
|
||||||
|
endif
|
||||||
|
endif
|
||||||
113
build/sync/README.md
Normal file
113
build/sync/README.md
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
sync
|
||||||
|
====
|
||||||
|
|
||||||
|
The sync tool is a proof-of-concept implementation of a tool for synchronizing mattermost plugin
|
||||||
|
repositories with the mattermost-plugin-starter-template repo.
|
||||||
|
|
||||||
|
Overview
|
||||||
|
--------
|
||||||
|
|
||||||
|
At its core the tool is just a collection of checks and actions that are executed according to a
|
||||||
|
synchronization plan (see [./build/sync/plan.yml](https://github.com/mattermost/mattermost-plugin-starter-template/blob/sync/build/sync/plan.yml)
|
||||||
|
for an example). The plan defines a set of files
|
||||||
|
and/or directories that need to be kept in sync between the plugin repository and the template (this
|
||||||
|
repo).
|
||||||
|
|
||||||
|
For each set of paths, a set of actions to be performed is outlined. No more than one action of that set
|
||||||
|
will be executed - the first one whose checks pass. Other actions are meant to act as fallbacks.
|
||||||
|
The idea is to be able to e.g. overwrite a file if it has no local changes or apply a format-specific
|
||||||
|
merge algorithm otherwise.
|
||||||
|
|
||||||
|
Before running each action, the tool will check if any checks are defined for that action. If there
|
||||||
|
are any, they will be executed and their results examined. If all checks pass, the action will be executed.
|
||||||
|
If there is a check failure, the tool will locate the next applicable action according to the plan and
|
||||||
|
start over with it.
|
||||||
|
|
||||||
|
The synchronization plan can also run checks before running any actions, e.g. to check if the source and
|
||||||
|
target worktrees are clean.
|
||||||
|
|
||||||
|
Running
|
||||||
|
-------
|
||||||
|
|
||||||
|
The tool can be executed from the root of this repository with a command:
|
||||||
|
```
|
||||||
|
$ go run ./build/sync/main.go ./build/sync/plan.yml ../mattermost-plugin-github
|
||||||
|
```
|
||||||
|
|
||||||
|
(assuming `mattermost-plugin-github` is the target repository we want to synchronize with the source).
|
||||||
|
|
||||||
|
plan.yml
|
||||||
|
---------
|
||||||
|
|
||||||
|
The `plan.yml` file (located in `build/sync/plan.yml`) consists of two parts:
|
||||||
|
- checks
|
||||||
|
- actions
|
||||||
|
|
||||||
|
The `checks` section defines tests to run before executing the plan itself. Currently the only available such check is `repo_is_clean` defined as:
|
||||||
|
```
|
||||||
|
type: repo_is_clean
|
||||||
|
params:
|
||||||
|
repo: source
|
||||||
|
```
|
||||||
|
The `repo` parameter takes one of two values:
|
||||||
|
- `source` - the `mattermost-plugin-starter-template` repository
|
||||||
|
- `target` - the repository of the plugin being updated.
|
||||||
|
|
||||||
|
The `actions` section defines actions to be run as part of the synchronization.
|
||||||
|
Each entry in this section has the form:
|
||||||
|
```
|
||||||
|
paths:
|
||||||
|
- path1
|
||||||
|
- path2
|
||||||
|
actions:
|
||||||
|
- type: action_type
|
||||||
|
params:
|
||||||
|
action_parameter: value
|
||||||
|
conditions:
|
||||||
|
- type: check_type
|
||||||
|
params:
|
||||||
|
check_parameter: value
|
||||||
|
```
|
||||||
|
|
||||||
|
`paths` is a list of file or directory paths (relative to the root of the repository)
|
||||||
|
synchronization should be performed on.
|
||||||
|
|
||||||
|
Each action in the `actions` section is defined by its type. Currently supported action types are:
|
||||||
|
- `overwrite_file` - overwrite the specified file in the `target` repository with the file in the `source` repository.
|
||||||
|
- `overwrite_directory` - overwrite a directory.
|
||||||
|
|
||||||
|
Both actions accept a parameter called `create` which determines if the file or directory should be created if it does not exist in the target repository.
|
||||||
|
|
||||||
|
The `conditions` part of an action definition defines tests that need to pass for the
|
||||||
|
action to be run. Available checks are:
|
||||||
|
- `exists`
|
||||||
|
- `file_unaltered`
|
||||||
|
|
||||||
|
The `exists` check takes a single parameter - `repo` (referencing either the source or target repository) and it passes only if the file or directory the action is about to be run on exists. If the repo parameter is not specified, it will default to `target`.
|
||||||
|
|
||||||
|
The `file_unaltered` check is only applicable to file paths. It passes if the file
|
||||||
|
has not been altered - i.e. it is identical to some version of that same file in the reference repository (usually `source`). This check takes two parameters:
|
||||||
|
- `in` - repository to check the file in, default `target`
|
||||||
|
- `compared-to` - repository to check the file against, default `source`.
|
||||||
|
|
||||||
|
When multiple actions are specified for a set of paths, the `sync` tool will only
|
||||||
|
execute a single action for each path. The first action in the list, whose conditions
|
||||||
|
are all satisfied will be executed.
|
||||||
|
|
||||||
|
If an acton fails due to an error, the synchronization run will be aborted.
|
||||||
|
|
||||||
|
Caveat emptor
|
||||||
|
-------------
|
||||||
|
|
||||||
|
This is a very basic proof-of-concept and there are many things that should be improved/implemented:
|
||||||
|
(in no specific order)
|
||||||
|
|
||||||
|
1. Format-specific merge actions for `go.mod`, `go.sum`, `webapp/package.json` and other files should
|
||||||
|
be implemented.
|
||||||
|
2. Better logging should be implemented.
|
||||||
|
3. Handling action dependencies should be investigated.
|
||||||
|
e.g. if the `build` directory is overwritten, that will in some cases mean that the go.mod file also needs
|
||||||
|
to be updated.
|
||||||
|
4. Storing the tree-hash of the template repository that the plugin was synchronized with would allow
|
||||||
|
improving the performance of the tool by restricting the search space when examining if a file
|
||||||
|
has been altered in the plugin repository.
|
||||||
85
build/sync/main.go
Normal file
85
build/sync/main.go
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"sigs.k8s.io/yaml"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
verbose := flag.Bool("verbose", false, "enable verbose output")
|
||||||
|
flag.Usage = func() {
|
||||||
|
fmt.Fprintf(flag.CommandLine.Output(), "Update a pluging directory with /mattermost-plugin-starter-template/.\n")
|
||||||
|
fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0])
|
||||||
|
fmt.Fprintf(flag.CommandLine.Output(), "%s <plan.yml> <plugin_directory>\n", os.Args[0])
|
||||||
|
flag.PrintDefaults()
|
||||||
|
}
|
||||||
|
|
||||||
|
flag.Parse()
|
||||||
|
// TODO: implement proper command line parameter parsing.
|
||||||
|
if len(os.Args) != 3 {
|
||||||
|
fmt.Fprintf(os.Stderr, "running: \n $ sync [plan.yaml] [plugin path]\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPlan, err := readPlan(os.Args[1])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "coud not load plan: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcDir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "failed to get current directory: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
trgDir, err := filepath.Abs(os.Args[2])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "could not determine target directory: %s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcRepo, err := plan.GetRepoSetup(srcDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
trgRepo, err := plan.GetRepoSetup(trgDir)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
planSetup := plan.Setup{
|
||||||
|
Source: srcRepo,
|
||||||
|
Target: trgRepo,
|
||||||
|
VerboseLogging: *verbose,
|
||||||
|
}
|
||||||
|
err = syncPlan.Execute(planSetup)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPlan(path string) (*plan.Plan, error) {
|
||||||
|
raw, err := ioutil.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read plan file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var p plan.Plan
|
||||||
|
err = yaml.Unmarshal(raw, &p)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal plan yaml: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &p, err
|
||||||
|
}
|
||||||
44
build/sync/plan.yml
Normal file
44
build/sync/plan.yml
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
checks:
|
||||||
|
- type: repo_is_clean
|
||||||
|
params:
|
||||||
|
repo: source
|
||||||
|
- type: repo_is_clean
|
||||||
|
params:
|
||||||
|
repo: target
|
||||||
|
actions:
|
||||||
|
- paths:
|
||||||
|
- build/pluginctl
|
||||||
|
- build/manifest
|
||||||
|
actions:
|
||||||
|
- type: overwrite_directory
|
||||||
|
params:
|
||||||
|
create: true
|
||||||
|
- paths:
|
||||||
|
- Makefile
|
||||||
|
actions:
|
||||||
|
- type: overwrite_file
|
||||||
|
params:
|
||||||
|
create: true
|
||||||
|
- paths:
|
||||||
|
- .editorconfig
|
||||||
|
- .gitattributes
|
||||||
|
- .gitignore
|
||||||
|
- build/.gitignore
|
||||||
|
- build/go.mod
|
||||||
|
- build/go.sum
|
||||||
|
- build/setup.mk
|
||||||
|
- server/.gitignore
|
||||||
|
- webapp/.eslintrc.json
|
||||||
|
- webapp/.npmrc
|
||||||
|
- webapp/babel.config.js
|
||||||
|
- webapp/package.json
|
||||||
|
- webapp/tsconfig.json
|
||||||
|
- webapp/webpack.config.js
|
||||||
|
- webapp/src/manifest.test.tsx
|
||||||
|
- webapp/tests/setup.tsx
|
||||||
|
actions:
|
||||||
|
- type: overwrite_file
|
||||||
|
params:
|
||||||
|
create: true
|
||||||
|
conditions:
|
||||||
|
- type: file_unaltered
|
||||||
214
build/sync/plan/actions.go
Normal file
214
build/sync/plan/actions.go
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActionConditions adds condition support to actions.
|
||||||
|
type ActionConditions struct {
|
||||||
|
// Conditions are checkers run before executing the
|
||||||
|
// action. If any one fails (returns an error), the action
|
||||||
|
// itself is not executed.
|
||||||
|
Conditions []Check
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check runs the conditions associated with the action and returns
|
||||||
|
// the first error (if any).
|
||||||
|
func (c ActionConditions) Check(path string, setup Setup) error {
|
||||||
|
if len(c.Conditions) > 0 {
|
||||||
|
setup.Logf("checking action conditions")
|
||||||
|
}
|
||||||
|
for _, condition := range c.Conditions {
|
||||||
|
err := condition.Check(path, setup)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverwriteFileAction is used to overwrite a file.
|
||||||
|
type OverwriteFileAction struct {
|
||||||
|
ActionConditions
|
||||||
|
Params struct {
|
||||||
|
// Create determines whether the target directory
|
||||||
|
// will be created if it does not exist.
|
||||||
|
Create bool `json:"create"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run implements plan.Action.Run.
|
||||||
|
func (a OverwriteFileAction) Run(path string, setup Setup) error {
|
||||||
|
setup.Logf("overwriting file %q", path)
|
||||||
|
src := setup.PathInRepo(SourceRepo, path)
|
||||||
|
dst := setup.PathInRepo(TargetRepo, path)
|
||||||
|
|
||||||
|
dstInfo, err := os.Stat(dst)
|
||||||
|
switch {
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
if !a.Params.Create {
|
||||||
|
return fmt.Errorf("path %q does not exist, not creating", dst)
|
||||||
|
}
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("failed to check path %q: %v", dst, err)
|
||||||
|
case dstInfo.IsDir():
|
||||||
|
return fmt.Errorf("path %q is a directory", dst)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcInfo, err := os.Stat(src)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("file %q does not exist", src)
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("failed to check path %q: %v", src, err)
|
||||||
|
}
|
||||||
|
if srcInfo.IsDir() {
|
||||||
|
return fmt.Errorf("path %q is a directory", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcF, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open %q: %v", src, err)
|
||||||
|
}
|
||||||
|
defer srcF.Close()
|
||||||
|
dstF, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, srcInfo.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open %q: %v", src, err)
|
||||||
|
}
|
||||||
|
defer dstF.Close()
|
||||||
|
_, err = io.Copy(dstF, srcF)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to copy file %q: %v", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OverwriteDirectoryAction is used to completely overwrite directories.
|
||||||
|
// If the target directory exists, it will be removed first.
|
||||||
|
type OverwriteDirectoryAction struct {
|
||||||
|
ActionConditions
|
||||||
|
Params struct {
|
||||||
|
// Create determines whether the target directory
|
||||||
|
// will be created if it does not exist.
|
||||||
|
Create bool `json:"create"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run implements plan.Action.Run.
|
||||||
|
func (a OverwriteDirectoryAction) Run(path string, setup Setup) error {
|
||||||
|
setup.Logf("overwriting directory %q", path)
|
||||||
|
src := setup.PathInRepo(SourceRepo, path)
|
||||||
|
dst := setup.PathInRepo(TargetRepo, path)
|
||||||
|
|
||||||
|
dstInfo, err := os.Stat(dst)
|
||||||
|
switch {
|
||||||
|
case os.IsNotExist(err):
|
||||||
|
if !a.Params.Create {
|
||||||
|
return fmt.Errorf("path %q does not exist, not creating", dst)
|
||||||
|
}
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("failed to check path %q: %v", dst, err)
|
||||||
|
default:
|
||||||
|
if !dstInfo.IsDir() {
|
||||||
|
return fmt.Errorf("path %q is not a directory", dst)
|
||||||
|
}
|
||||||
|
err = os.RemoveAll(dst)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to remove directory %q: %v", dst, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srcInfo, err := os.Stat(src)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("directory %q does not exist", src)
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("failed to check path %q: %v", src, err)
|
||||||
|
}
|
||||||
|
if !srcInfo.IsDir() {
|
||||||
|
return fmt.Errorf("path %q is not a directory", src)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = CopyDirectory(src, dst)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to copy path %q: %v", path, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CopyDirectory copies the directory src to dst so that after
|
||||||
|
// a successful operation the contents of src and dst are equal.
|
||||||
|
func CopyDirectory(src, dst string) error {
|
||||||
|
copier := dirCopier{dst: dst, src: src}
|
||||||
|
return filepath.Walk(src, copier.Copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
type dirCopier struct {
|
||||||
|
dst string
|
||||||
|
src string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a path in the source directory to a path in the destination
|
||||||
|
// directory.
|
||||||
|
func (d dirCopier) srcToDst(path string) (string, error) {
|
||||||
|
suff := strings.TrimPrefix(path, d.src)
|
||||||
|
if suff == path {
|
||||||
|
return "", fmt.Errorf("path %q is not in %q", path, d.src)
|
||||||
|
}
|
||||||
|
return filepath.Join(d.dst, suff), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy is an implementation of filepatch.WalkFunc that copies the
|
||||||
|
// source directory to target with all subdirectories.
|
||||||
|
func (d dirCopier) Copy(srcPath string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to copy directory: %v", err)
|
||||||
|
}
|
||||||
|
trgPath, err := d.srcToDst(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
err = os.MkdirAll(trgPath, info.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory %q: %v", trgPath, err)
|
||||||
|
}
|
||||||
|
err = os.Chtimes(trgPath, info.ModTime(), info.ModTime())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create directory %q: %v", trgPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
err = copyFile(srcPath, trgPath, info)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to copy file %q: %v", srcPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string, info os.FileInfo) error {
|
||||||
|
srcF, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open source file %q: %v", src, err)
|
||||||
|
}
|
||||||
|
defer srcF.Close()
|
||||||
|
dstF, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, info.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open destination file %q: %v", dst, err)
|
||||||
|
}
|
||||||
|
_, err = io.Copy(dstF, srcF)
|
||||||
|
if err != nil {
|
||||||
|
dstF.Close()
|
||||||
|
return fmt.Errorf("failed to copy file %q: %v", src, err)
|
||||||
|
}
|
||||||
|
if err = dstF.Close(); err != nil {
|
||||||
|
return fmt.Errorf("failed to close file %q: %v", dst, err)
|
||||||
|
}
|
||||||
|
err = os.Chtimes(dst, info.ModTime(), info.ModTime())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to adjust file modification time for %q: %v", dst, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
112
build/sync/plan/actions_test.go
Normal file
112
build/sync/plan/actions_test.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package plan_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCopyDirectory(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Create a temporary directory to copy to.
|
||||||
|
dir, err := ioutil.TempDir("", "test")
|
||||||
|
assert.Nil(err)
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
srcDir := filepath.Join(wd, "testdata")
|
||||||
|
err = plan.CopyDirectory(srcDir, dir)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
compareDirectories(t, dir, srcDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverwriteFileAction(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Create a temporary directory to copy to.
|
||||||
|
dir, err := ioutil.TempDir("", "test")
|
||||||
|
assert.Nil(err)
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
setup := plan.Setup{
|
||||||
|
Source: plan.RepoSetup{
|
||||||
|
Git: nil,
|
||||||
|
Path: filepath.Join(wd, "testdata", "b"),
|
||||||
|
},
|
||||||
|
Target: plan.RepoSetup{
|
||||||
|
Git: nil,
|
||||||
|
Path: dir,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
action := plan.OverwriteFileAction{}
|
||||||
|
action.Params.Create = true
|
||||||
|
err = action.Run("c", setup)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
compareDirectories(t, dir, filepath.Join(wd, "testdata", "b"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverwriteDirectoryAction(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Create a temporary directory to copy to.
|
||||||
|
dir, err := ioutil.TempDir("", "test")
|
||||||
|
assert.Nil(err)
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
setup := plan.Setup{
|
||||||
|
Source: plan.RepoSetup{
|
||||||
|
Git: nil,
|
||||||
|
Path: wd,
|
||||||
|
},
|
||||||
|
Target: plan.RepoSetup{
|
||||||
|
Git: nil,
|
||||||
|
Path: dir,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
action := plan.OverwriteDirectoryAction{}
|
||||||
|
action.Params.Create = true
|
||||||
|
err = action.Run("testdata", setup)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
destDir := filepath.Join(dir, "testdata")
|
||||||
|
srcDir := filepath.Join(wd, "testdata")
|
||||||
|
compareDirectories(t, destDir, srcDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareDirectories(t *testing.T, pathA, pathB string) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
aContents, err := ioutil.ReadDir(pathA)
|
||||||
|
assert.Nil(err)
|
||||||
|
bContents, err := ioutil.ReadDir(pathB)
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Len(aContents, len(bContents))
|
||||||
|
|
||||||
|
// Check the directory contents are equal.
|
||||||
|
for i, aFInfo := range aContents {
|
||||||
|
bFInfo := bContents[i]
|
||||||
|
assert.Equal(aFInfo.Name(), bFInfo.Name())
|
||||||
|
assert.Equal(aFInfo.Mode(), bFInfo.Mode())
|
||||||
|
assert.Equal(aFInfo.IsDir(), bFInfo.IsDir())
|
||||||
|
if !aFInfo.IsDir() {
|
||||||
|
assert.Equal(aFInfo.Size(), bFInfo.Size())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
176
build/sync/plan/checks.go
Normal file
176
build/sync/plan/checks.go
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckFail is a custom error type used to indicate a
|
||||||
|
// check that did not pass (but did not fail due to external
|
||||||
|
// causes.
|
||||||
|
// Use `IsCheckFail` to check if an error is a check failure.
|
||||||
|
type CheckFail string
|
||||||
|
|
||||||
|
func (e CheckFail) Error() string {
|
||||||
|
return string(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckFailf creates an error with the specified message string.
|
||||||
|
// The error will pass the IsCheckFail filter.
|
||||||
|
func CheckFailf(msg string, args ...interface{}) CheckFail {
|
||||||
|
if len(args) > 0 {
|
||||||
|
msg = fmt.Sprintf(msg, args...)
|
||||||
|
}
|
||||||
|
return CheckFail(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCheckFail determines if an error is a check fail error.
|
||||||
|
func IsCheckFail(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := err.(CheckFail)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoIsCleanChecker checks whether the git repository is clean.
|
||||||
|
type RepoIsCleanChecker struct {
|
||||||
|
Params struct {
|
||||||
|
Repo RepoID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check implements the Checker interface.
|
||||||
|
// The path parameter is ignored because this checker checks the state of a repository.
|
||||||
|
func (r RepoIsCleanChecker) Check(_ string, ctx Setup) error {
|
||||||
|
ctx.Logf("checking if repository %q is clean", r.Params.Repo)
|
||||||
|
rc := ctx.GetRepo(r.Params.Repo)
|
||||||
|
repo := rc.Git
|
||||||
|
worktree, err := repo.Worktree()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get worktree: %v", err)
|
||||||
|
}
|
||||||
|
status, err := worktree.Status()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get worktree status: %v", err)
|
||||||
|
}
|
||||||
|
if !status.IsClean() {
|
||||||
|
return CheckFailf("%q repository is not clean", r.Params.Repo)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathExistsChecker checks whether the fle or directory with the
|
||||||
|
// path exists. If it does not, an error is returned.
|
||||||
|
type PathExistsChecker struct {
|
||||||
|
Params struct {
|
||||||
|
Repo RepoID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check implements the Checker interface.
|
||||||
|
func (r PathExistsChecker) Check(path string, ctx Setup) error {
|
||||||
|
repo := r.Params.Repo
|
||||||
|
if repo == "" {
|
||||||
|
repo = TargetRepo
|
||||||
|
}
|
||||||
|
ctx.Logf("checking if path %q exists in repo %q", path, repo)
|
||||||
|
absPath := ctx.PathInRepo(repo, path)
|
||||||
|
_, err := os.Stat(absPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return CheckFailf("path %q does not exist", path)
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat path %q: %v", absPath, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileUnalteredChecker checks whether the file in Repo is
|
||||||
|
// an unaltered version of that same file in ReferenceRepo.
|
||||||
|
//
|
||||||
|
// Its purpose is to check that a file has not been changed after forking a repository.
|
||||||
|
// It could be an old unaltered version, so the git history of the file is traversed
|
||||||
|
// until a matching version is found.
|
||||||
|
//
|
||||||
|
// If the repositories in the parameters are not specified,
|
||||||
|
// reference will default to the source repository and repo - to the target.
|
||||||
|
type FileUnalteredChecker struct {
|
||||||
|
Params struct {
|
||||||
|
SourceRepo RepoID `json:"compared-to"`
|
||||||
|
TargetRepo RepoID `json:"in"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check implements the Checker interface.
|
||||||
|
func (f FileUnalteredChecker) Check(path string, setup Setup) error {
|
||||||
|
setup.Logf("checking if file %q has not been altered", path)
|
||||||
|
repo := f.Params.TargetRepo
|
||||||
|
if repo == "" {
|
||||||
|
repo = TargetRepo
|
||||||
|
}
|
||||||
|
source := f.Params.SourceRepo
|
||||||
|
if source == "" {
|
||||||
|
source = SourceRepo
|
||||||
|
}
|
||||||
|
trgPath := setup.PathInRepo(repo, path)
|
||||||
|
srcPath := setup.PathInRepo(source, path)
|
||||||
|
|
||||||
|
fileHashes, err := git.FileHistory(path, setup.GetRepo(source).Git)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var srcDeleted bool
|
||||||
|
srcInfo, err := os.Stat(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
srcDeleted = true
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("failed to get stat for %q: %v", trgPath, err)
|
||||||
|
}
|
||||||
|
} else if srcInfo.IsDir() {
|
||||||
|
return fmt.Errorf("%q is a directory in source repository", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
trgInfo, err := os.Stat(trgPath)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
if srcDeleted {
|
||||||
|
// File has been deleted in target and source repositories.
|
||||||
|
// Consider it unaltered.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Check if the file was ever in git history.
|
||||||
|
_, err := git.FileHistory(path, setup.GetRepo(repo).Git)
|
||||||
|
if errors.Is(err, git.ErrNotFound) {
|
||||||
|
// This is a new file being introduced to the target repo.
|
||||||
|
// Consider it unaltered.
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return CheckFailf("file %q has been deleted", trgPath)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get stat for %q: %v", trgPath, err)
|
||||||
|
}
|
||||||
|
if trgInfo.IsDir() {
|
||||||
|
return fmt.Errorf("%q is a directory", trgPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentHash, err := git.GetFileHash(trgPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(fileHashes)
|
||||||
|
idx := sort.SearchStrings(fileHashes, currentHash)
|
||||||
|
if idx < len(fileHashes) && fileHashes[idx] == currentHash {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return CheckFailf("file %q has been altered", trgPath)
|
||||||
|
}
|
||||||
213
build/sync/plan/checks_test.go
Normal file
213
build/sync/plan/checks_test.go
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
package plan_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tests for the RepoIsClean checker.
|
||||||
|
func TestRepoIsCleanChecker(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Create a git repository in a temporary dir.
|
||||||
|
dir, err := ioutil.TempDir("", "test")
|
||||||
|
assert.Nil(err)
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
repo, err := git.PlainInit(dir, false)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
// Repo should be clean.
|
||||||
|
checker := plan.RepoIsCleanChecker{}
|
||||||
|
checker.Params.Repo = plan.TargetRepo
|
||||||
|
|
||||||
|
ctx := plan.Setup{
|
||||||
|
Target: plan.RepoSetup{
|
||||||
|
Path: dir,
|
||||||
|
Git: repo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
assert.Nil(checker.Check("", ctx))
|
||||||
|
|
||||||
|
// Create a file in the repository.
|
||||||
|
err = ioutil.WriteFile(path.Join(dir, "data.txt"), []byte("lorem ipsum"), 0600)
|
||||||
|
assert.Nil(err)
|
||||||
|
err = checker.Check("", ctx)
|
||||||
|
assert.EqualError(err, "\"target\" repository is not clean")
|
||||||
|
assert.True(plan.IsCheckFail(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPathExistsChecker(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
// Set up a working directory.
|
||||||
|
wd, err := ioutil.TempDir("", "repo")
|
||||||
|
assert.Nil(err)
|
||||||
|
defer os.RemoveAll(wd)
|
||||||
|
err = os.Mkdir(filepath.Join(wd, "t"), 0755)
|
||||||
|
assert.Nil(err)
|
||||||
|
err = ioutil.WriteFile(filepath.Join(wd, "t", "test"), []byte("lorem ipsum"), 0644)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
checker := plan.PathExistsChecker{}
|
||||||
|
checker.Params.Repo = plan.SourceRepo
|
||||||
|
|
||||||
|
ctx := plan.Setup{
|
||||||
|
Source: plan.RepoSetup{
|
||||||
|
Path: wd,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check with existing directory.
|
||||||
|
assert.Nil(checker.Check("t", ctx))
|
||||||
|
|
||||||
|
// Check with existing file.
|
||||||
|
assert.Nil(checker.Check("t/test", ctx))
|
||||||
|
|
||||||
|
err = checker.Check("nosuchpath", ctx)
|
||||||
|
assert.NotNil(err)
|
||||||
|
assert.True(plan.IsCheckFail(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
func tempGitRepo(assert *assert.Assertions) (string, *git.Repository, func()) {
|
||||||
|
// Setup repository.
|
||||||
|
wd, err := ioutil.TempDir("", "repo")
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
// Initialize a repository.
|
||||||
|
repo, err := git.PlainInit(wd, false)
|
||||||
|
assert.Nil(err)
|
||||||
|
w, err := repo.Worktree()
|
||||||
|
assert.Nil(err)
|
||||||
|
// Create repository files.
|
||||||
|
err = ioutil.WriteFile(filepath.Join(wd, "test"),
|
||||||
|
[]byte("lorem ipsum"), 0644)
|
||||||
|
assert.Nil(err)
|
||||||
|
sig := &object.Signature{
|
||||||
|
Name: "test",
|
||||||
|
Email: "test@example.com",
|
||||||
|
When: time.Now(),
|
||||||
|
}
|
||||||
|
_, err = w.Commit("initial commit", &git.CommitOptions{Author: sig})
|
||||||
|
assert.Nil(err)
|
||||||
|
pathA := "a.txt"
|
||||||
|
err = ioutil.WriteFile(filepath.Join(wd, pathA),
|
||||||
|
[]byte("lorem ipsum"), 0644)
|
||||||
|
assert.Nil(err)
|
||||||
|
_, err = w.Add(pathA)
|
||||||
|
assert.Nil(err)
|
||||||
|
_, err = w.Commit("add files", &git.CommitOptions{Author: sig})
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
return wd, repo, func() { os.RemoveAll(wd) }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnalteredCheckerSameFile(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
wd, repo, cleanup := tempGitRepo(assert)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := plan.Setup{
|
||||||
|
Source: plan.RepoSetup{
|
||||||
|
Path: wd,
|
||||||
|
Git: repo,
|
||||||
|
},
|
||||||
|
Target: plan.RepoSetup{
|
||||||
|
Path: wd,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := plan.FileUnalteredChecker{}
|
||||||
|
checker.Params.SourceRepo = plan.SourceRepo
|
||||||
|
checker.Params.TargetRepo = plan.TargetRepo
|
||||||
|
|
||||||
|
// Check with the same file - check should succeed
|
||||||
|
hashPath := "a.txt"
|
||||||
|
err := checker.Check(hashPath, ctx)
|
||||||
|
assert.Nil(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnalteredCheckerDifferentContents(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
wd, repo, cleanup := tempGitRepo(assert)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := plan.Setup{
|
||||||
|
Source: plan.RepoSetup{
|
||||||
|
Path: wd,
|
||||||
|
Git: repo,
|
||||||
|
},
|
||||||
|
Target: plan.RepoSetup{
|
||||||
|
Path: wd,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := plan.FileUnalteredChecker{}
|
||||||
|
checker.Params.SourceRepo = plan.SourceRepo
|
||||||
|
checker.Params.TargetRepo = plan.TargetRepo
|
||||||
|
|
||||||
|
// Create a file with the same suffix path, but different contents.
|
||||||
|
tmpDir, err := ioutil.TempDir("", "test")
|
||||||
|
assert.Nil(err)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
err = ioutil.WriteFile(filepath.Join(tmpDir, "a.txt"),
|
||||||
|
[]byte("not lorem ipsum"), 0644)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
// Set the plugin path to the temporary directory.
|
||||||
|
ctx.Target.Path = tmpDir
|
||||||
|
err = checker.Check("a.txt", ctx)
|
||||||
|
assert.True(plan.IsCheckFail(err))
|
||||||
|
assert.EqualError(err, fmt.Sprintf("file %q has been altered", filepath.Join(tmpDir, "a.txt")))
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnalteredCheckerNonExistant tests running the unaltered file checker
|
||||||
|
// in the case where the target file does not exist. If the files has no history,
|
||||||
|
// the checker should pass.
|
||||||
|
func TestUnalteredCheckerNonExistant(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
hashPath := "a.txt"
|
||||||
|
|
||||||
|
wd, repo, cleanup := tempGitRepo(assert)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// Temporary repo.
|
||||||
|
tmpDir, err := ioutil.TempDir("", "test")
|
||||||
|
assert.Nil(err)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
trgRepo, err := git.PlainInit(tmpDir, false)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
ctx := plan.Setup{
|
||||||
|
Source: plan.RepoSetup{
|
||||||
|
Path: wd,
|
||||||
|
Git: repo,
|
||||||
|
},
|
||||||
|
Target: plan.RepoSetup{
|
||||||
|
Path: tmpDir,
|
||||||
|
Git: trgRepo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
checker := plan.FileUnalteredChecker{}
|
||||||
|
checker.Params.SourceRepo = plan.SourceRepo
|
||||||
|
checker.Params.TargetRepo = plan.TargetRepo
|
||||||
|
|
||||||
|
err = checker.Check(hashPath, ctx)
|
||||||
|
assert.Nil(err)
|
||||||
|
}
|
||||||
111
build/sync/plan/git/file_history.go
Normal file
111
build/sync/plan/git/file_history.go
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha1" //nolint
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotFound signifies the file was not found.
|
||||||
|
var ErrNotFound = fmt.Errorf("not found")
|
||||||
|
|
||||||
|
// FileHistory will trace all the versions of a file in the git repository
|
||||||
|
// and return a list of sha1 hashes of that file.
|
||||||
|
func FileHistory(path string, repo *git.Repository) ([]string, error) {
|
||||||
|
logOpts := git.LogOptions{
|
||||||
|
FileName: &path,
|
||||||
|
All: true,
|
||||||
|
}
|
||||||
|
commits, err := repo.Log(&logOpts)
|
||||||
|
if errors.Is(err, plumbing.ErrReferenceNotFound) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get commits for path %q: %v", path, err)
|
||||||
|
}
|
||||||
|
defer commits.Close()
|
||||||
|
hashHistory := []string{}
|
||||||
|
cerr := commits.ForEach(func(c *object.Commit) error {
|
||||||
|
root, err := repo.TreeObject(c.TreeHash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get commit tree: %v", err)
|
||||||
|
}
|
||||||
|
f, err := traverseTree(root, path)
|
||||||
|
if err == object.ErrFileNotFound || err == object.ErrDirectoryNotFound {
|
||||||
|
// Ignoring file not found errors.
|
||||||
|
return nil
|
||||||
|
} else if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sum, err := getReaderHash(f)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
hashHistory = append(hashHistory, sum)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if cerr != nil && cerr != io.EOF {
|
||||||
|
return nil, cerr
|
||||||
|
}
|
||||||
|
if len(hashHistory) == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return hashHistory, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func traverseTree(root *object.Tree, path string) (io.ReadCloser, error) {
|
||||||
|
dirName, fileName := filepath.Split(path)
|
||||||
|
var err error
|
||||||
|
t := root
|
||||||
|
if dirName != "" {
|
||||||
|
t, err = root.Tree(filepath.Clean(dirName))
|
||||||
|
if err == object.ErrDirectoryNotFound {
|
||||||
|
return nil, err
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to traverse tree to %q: %v", dirName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f, err := t.File(fileName)
|
||||||
|
if err == object.ErrFileNotFound {
|
||||||
|
return nil, err
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to lookup file %q: %v", fileName, err)
|
||||||
|
}
|
||||||
|
reader, err := f.Reader()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open %q: %v", path, err)
|
||||||
|
}
|
||||||
|
return reader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getReaderHash(r io.Reader) (string, error) {
|
||||||
|
h := sha1.New() // nolint
|
||||||
|
_, err := io.Copy(h, r)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileHash calculates the sha1 hash sum of the file.
|
||||||
|
func GetFileHash(path string) (string, error) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sum, err := getReaderHash(f)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return sum, nil
|
||||||
|
}
|
||||||
80
build/sync/plan/git/file_history_test.go
Normal file
80
build/sync/plan/git/file_history_test.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package git_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
"github.com/go-git/go-git/v5/plumbing/object"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
gitutil "github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan/git"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fileContents = []byte("abcdefg")
|
||||||
|
|
||||||
|
func TestFileHistory(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
dir, err := ioutil.TempDir("", "repo")
|
||||||
|
assert.Nil(err)
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
// Initialize a repository.
|
||||||
|
repo, err := git.PlainInit(dir, false)
|
||||||
|
assert.Nil(err)
|
||||||
|
w, err := repo.Worktree()
|
||||||
|
assert.Nil(err)
|
||||||
|
// Create repository files.
|
||||||
|
err = ioutil.WriteFile(filepath.Join(dir, "test"), fileContents, 0644)
|
||||||
|
assert.Nil(err)
|
||||||
|
_, err = w.Add("test")
|
||||||
|
assert.Nil(err)
|
||||||
|
sig := &object.Signature{
|
||||||
|
Name: "test",
|
||||||
|
Email: "test@example.com",
|
||||||
|
When: time.Now(),
|
||||||
|
}
|
||||||
|
_, err = w.Commit("initial commit", &git.CommitOptions{Author: sig})
|
||||||
|
assert.Nil(err)
|
||||||
|
pathA := "a.txt"
|
||||||
|
err = ioutil.WriteFile(filepath.Join(dir, pathA), fileContents, 0644)
|
||||||
|
assert.Nil(err)
|
||||||
|
pathB := "b.txt"
|
||||||
|
err = ioutil.WriteFile(filepath.Join(dir, pathB), fileContents, 0644)
|
||||||
|
assert.Nil(err)
|
||||||
|
_, err = w.Add(pathA)
|
||||||
|
assert.Nil(err)
|
||||||
|
_, err = w.Add(pathB)
|
||||||
|
assert.Nil(err)
|
||||||
|
_, err = w.Commit("add files", &git.CommitOptions{Author: sig})
|
||||||
|
assert.Nil(err)
|
||||||
|
// Delete one of the files.
|
||||||
|
_, err = w.Remove(pathB)
|
||||||
|
assert.Nil(err)
|
||||||
|
_, err = w.Commit("remove file b.txt", &git.CommitOptions{
|
||||||
|
Author: sig,
|
||||||
|
All: true,
|
||||||
|
})
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
repo, err = git.PlainOpen(dir)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
// Call file history on an existing file.
|
||||||
|
sums, err := gitutil.FileHistory("a.txt", repo)
|
||||||
|
assert.Nil(err)
|
||||||
|
assert.Equal([]string{"2fb5e13419fc89246865e7a324f476ec624e8740"}, sums)
|
||||||
|
|
||||||
|
// Calling with a non-existent file returns error.
|
||||||
|
sums, err = gitutil.FileHistory(filepath.Join(dir, "nosuch_testfile.txt"), repo)
|
||||||
|
assert.Equal(gitutil.ErrNotFound, err)
|
||||||
|
assert.Nil(sums)
|
||||||
|
|
||||||
|
// Calling with a non-existent file that was in git history returns no error.
|
||||||
|
_, err = gitutil.FileHistory(pathB, repo)
|
||||||
|
assert.Nil(err)
|
||||||
|
}
|
||||||
245
build/sync/plan/plan.go
Normal file
245
build/sync/plan/plan.go
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
// Package plan handles the synchronization plan.
|
||||||
|
//
|
||||||
|
// Each synchronization plan is a set of checks and actions to perform on specified paths
|
||||||
|
// that will result in the "plugin" repository being updated.
|
||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plan defines the plan for synchronizing a target and a source directory.
|
||||||
|
type Plan struct {
|
||||||
|
Checks []Check `json:"checks"`
|
||||||
|
// Each set of paths has multiple actions associated, each a fallback for the one
|
||||||
|
// previous to it.
|
||||||
|
Actions []ActionSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshalJSON implements the `json.Unmarshaler` interface.
|
||||||
|
func (p *Plan) UnmarshalJSON(raw []byte) error {
|
||||||
|
var t jsonPlan
|
||||||
|
if err := json.Unmarshal(raw, &t); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.Checks = make([]Check, len(t.Checks))
|
||||||
|
for i, check := range t.Checks {
|
||||||
|
c, err := parseCheck(check.Type, check.Params)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse check %q: %v", check.Type, err)
|
||||||
|
}
|
||||||
|
p.Checks[i] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(t.Actions) > 0 {
|
||||||
|
p.Actions = make([]ActionSet, len(t.Actions))
|
||||||
|
}
|
||||||
|
for i, actionSet := range t.Actions {
|
||||||
|
var err error
|
||||||
|
pathActions := make([]Action, len(actionSet.Actions))
|
||||||
|
for i, action := range actionSet.Actions {
|
||||||
|
var actionConditions []Check
|
||||||
|
if len(action.Conditions) > 0 {
|
||||||
|
actionConditions = make([]Check, len(action.Conditions))
|
||||||
|
}
|
||||||
|
for j, check := range action.Conditions {
|
||||||
|
actionConditions[j], err = parseCheck(check.Type, check.Params)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pathActions[i], err = parseAction(action.Type, action.Params, actionConditions)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.Actions[i] = ActionSet{
|
||||||
|
Paths: actionSet.Paths,
|
||||||
|
Actions: pathActions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute executes the synchronization plan.
|
||||||
|
func (p *Plan) Execute(c Setup) error {
|
||||||
|
c.Logf("running pre-checks")
|
||||||
|
for _, check := range p.Checks {
|
||||||
|
err := check.Check("", c) // For pre-sync checks, the path is ignored.
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed check: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := []pathResult{}
|
||||||
|
c.Logf("running actions")
|
||||||
|
for _, actions := range p.Actions {
|
||||||
|
PATHS_LOOP:
|
||||||
|
for _, path := range actions.Paths {
|
||||||
|
c.Logf("syncing path %q", path)
|
||||||
|
ACTIONS_LOOP:
|
||||||
|
for i, action := range actions.Actions {
|
||||||
|
c.Logf("running action for path %q", path)
|
||||||
|
err := action.Check(path, c)
|
||||||
|
if IsCheckFail(err) {
|
||||||
|
c.Logf("check failed, not running action: %v", err)
|
||||||
|
// If a check for an action fails, we switch to
|
||||||
|
// the next action associated with the path.
|
||||||
|
if i == len(actions.Actions)-1 { // no actions to fallback to.
|
||||||
|
c.Logf("path %q not handled - no more fallbacks", path)
|
||||||
|
result = append(result,
|
||||||
|
pathResult{
|
||||||
|
Path: path,
|
||||||
|
Status: statusFailed,
|
||||||
|
Message: fmt.Sprintf("check failed, %s", err.Error()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
continue ACTIONS_LOOP
|
||||||
|
} else if err != nil {
|
||||||
|
c.LogErrorf("unexpected error when running check: %v", err)
|
||||||
|
return fmt.Errorf("failed to run checks for action: %v", err)
|
||||||
|
}
|
||||||
|
err = action.Run(path, c)
|
||||||
|
if err != nil {
|
||||||
|
c.LogErrorf("action failed: %v", err)
|
||||||
|
return fmt.Errorf("action failed: %v", err)
|
||||||
|
}
|
||||||
|
c.Logf("path %q sync'ed successfully", path)
|
||||||
|
result = append(result,
|
||||||
|
pathResult{
|
||||||
|
Path: path,
|
||||||
|
Status: statusUpdated,
|
||||||
|
})
|
||||||
|
|
||||||
|
continue PATHS_LOOP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print execution result.
|
||||||
|
sort.SliceStable(result, func(i, j int) bool { return result[i].Path < result[j].Path })
|
||||||
|
for _, res := range result {
|
||||||
|
if res.Message != "" {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\t%s: %s\n", res.Status, res.Path, res.Message)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stdout, "%s\t%s\n", res.Status, res.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check returns an error if the condition fails.
|
||||||
|
type Check interface {
|
||||||
|
Check(string, Setup) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActionSet is a set of actions along with a set of paths to
|
||||||
|
// perform those actions on.
|
||||||
|
type ActionSet struct {
|
||||||
|
Paths []string
|
||||||
|
Actions []Action
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action runs the defined action.
|
||||||
|
type Action interface {
|
||||||
|
// Run performs the action on the specified path.
|
||||||
|
Run(string, Setup) error
|
||||||
|
// Check runs checks associated with the action
|
||||||
|
// before running it.
|
||||||
|
Check(string, Setup) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonPlan is used to unmarshal Plan structures.
|
||||||
|
type jsonPlan struct {
|
||||||
|
Checks []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
}
|
||||||
|
Actions []struct {
|
||||||
|
Paths []string `json:"paths"`
|
||||||
|
Actions []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Params json.RawMessage `json:"params,omitempty"`
|
||||||
|
Conditions []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Params json.RawMessage `json:"params"`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCheck(checkType string, rawParams json.RawMessage) (Check, error) {
|
||||||
|
var c Check
|
||||||
|
|
||||||
|
var params interface{}
|
||||||
|
|
||||||
|
switch checkType {
|
||||||
|
case "repo_is_clean":
|
||||||
|
tc := RepoIsCleanChecker{}
|
||||||
|
params = &tc.Params
|
||||||
|
c = &tc
|
||||||
|
case "exists":
|
||||||
|
tc := PathExistsChecker{}
|
||||||
|
params = &tc.Params
|
||||||
|
c = &tc
|
||||||
|
case "file_unaltered":
|
||||||
|
tc := FileUnalteredChecker{}
|
||||||
|
params = &tc.Params
|
||||||
|
c = &tc
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown checker type %q", checkType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rawParams) > 0 {
|
||||||
|
err := json.Unmarshal(rawParams, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal params for %s: %v", checkType, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAction(actionType string, rawParams json.RawMessage, checks []Check) (Action, error) {
|
||||||
|
var a Action
|
||||||
|
|
||||||
|
var params interface{}
|
||||||
|
|
||||||
|
switch actionType {
|
||||||
|
case "overwrite_file":
|
||||||
|
ta := OverwriteFileAction{}
|
||||||
|
ta.Conditions = checks
|
||||||
|
params = &ta.Params
|
||||||
|
a = &ta
|
||||||
|
case "overwrite_directory":
|
||||||
|
ta := OverwriteDirectoryAction{}
|
||||||
|
ta.Conditions = checks
|
||||||
|
params = &ta.Params
|
||||||
|
a = &ta
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unknown action type %q", actionType)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rawParams) > 0 {
|
||||||
|
err := json.Unmarshal(rawParams, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to unmarshal params for %s: %v", actionType, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pathResult contains the result of synchronizing a path.
|
||||||
|
type pathResult struct {
|
||||||
|
Path string
|
||||||
|
Status status
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type status string
|
||||||
|
|
||||||
|
const (
|
||||||
|
statusUpdated status = "UPDATED"
|
||||||
|
statusFailed status = "FAILED"
|
||||||
|
)
|
||||||
253
build/sync/plan/plan_test.go
Normal file
253
build/sync/plan/plan_test.go
Normal file
@ -0,0 +1,253 @@
|
|||||||
|
package plan_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-plugin-starter-template/build/sync/plan"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUnmarshalPlan(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
rawJSON := []byte(`
|
||||||
|
{
|
||||||
|
"checks": [
|
||||||
|
{"type": "repo_is_clean", "params": {"repo": "template"}}
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"paths": ["abc"],
|
||||||
|
"actions": [{
|
||||||
|
"type": "overwrite_file",
|
||||||
|
"params": {"create": true},
|
||||||
|
"conditions": [{
|
||||||
|
"type": "exists",
|
||||||
|
"params": {"repo": "plugin"}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
var p plan.Plan
|
||||||
|
err := json.Unmarshal(rawJSON, &p)
|
||||||
|
assert.Nil(err)
|
||||||
|
expectedCheck := plan.RepoIsCleanChecker{}
|
||||||
|
expectedCheck.Params.Repo = "template"
|
||||||
|
|
||||||
|
expectedAction := plan.OverwriteFileAction{}
|
||||||
|
expectedAction.Params.Create = true
|
||||||
|
expectedActionCheck := plan.PathExistsChecker{}
|
||||||
|
expectedActionCheck.Params.Repo = "plugin"
|
||||||
|
expectedAction.Conditions = []plan.Check{&expectedActionCheck}
|
||||||
|
expected := plan.Plan{
|
||||||
|
Checks: []plan.Check{&expectedCheck},
|
||||||
|
Actions: []plan.ActionSet{{
|
||||||
|
Paths: []string{"abc"},
|
||||||
|
Actions: []plan.Action{
|
||||||
|
&expectedAction,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
assert.Equal(expected, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockCheck struct {
|
||||||
|
returnErr error
|
||||||
|
calledWith string // Path parameter the check was called with.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check implements the plan.Check interface.
|
||||||
|
func (m *mockCheck) Check(path string, c plan.Setup) error {
|
||||||
|
m.calledWith = path
|
||||||
|
return m.returnErr
|
||||||
|
}
|
||||||
|
|
||||||
|
type mockAction struct {
|
||||||
|
runErr error // Error to be returned by Run.
|
||||||
|
checkErr error // Error to be returned by Check.
|
||||||
|
calledWith string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check implements plan.Action interface.
|
||||||
|
func (m *mockAction) Check(path string, c plan.Setup) error {
|
||||||
|
return m.checkErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run implements plan.Action interface.
|
||||||
|
func (m *mockAction) Run(path string, c plan.Setup) error {
|
||||||
|
m.calledWith = path
|
||||||
|
return m.runErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunPlanSuccessfully tests a successful execution of a sync plan.
|
||||||
|
func TestRunPlanSuccessfully(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
|
||||||
|
|
||||||
|
preCheck := &mockCheck{}
|
||||||
|
action1 := &mockAction{}
|
||||||
|
action2 := &mockAction{}
|
||||||
|
|
||||||
|
p := &plan.Plan{
|
||||||
|
Checks: []plan.Check{preCheck},
|
||||||
|
Actions: []plan.ActionSet{{
|
||||||
|
Paths: []string{"somepath"},
|
||||||
|
Actions: []plan.Action{
|
||||||
|
action1,
|
||||||
|
action2,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
err := p.Execute(setup)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
assert.Equal("", preCheck.calledWith)
|
||||||
|
assert.Equal("somepath", action1.calledWith)
|
||||||
|
assert.Equal("", action2.calledWith) // second action was not called.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunPlanPreCheckFail checks the scenario where a sync plan precheck
|
||||||
|
// fails, aborting the whole operation.
|
||||||
|
func TestRunPlanPreCheckFail(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
|
||||||
|
|
||||||
|
preCheck := &mockCheck{returnErr: plan.CheckFailf("check failed")}
|
||||||
|
action1 := &mockAction{}
|
||||||
|
action2 := &mockAction{}
|
||||||
|
|
||||||
|
p := &plan.Plan{
|
||||||
|
Checks: []plan.Check{preCheck},
|
||||||
|
Actions: []plan.ActionSet{{
|
||||||
|
Paths: []string{"somepath"},
|
||||||
|
Actions: []plan.Action{
|
||||||
|
action1,
|
||||||
|
action2,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
err := p.Execute(setup)
|
||||||
|
assert.EqualError(err, "failed check: check failed")
|
||||||
|
|
||||||
|
assert.Equal("", preCheck.calledWith)
|
||||||
|
// None of the actions were executed.
|
||||||
|
assert.Equal("", action1.calledWith)
|
||||||
|
assert.Equal("", action2.calledWith)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunPlanActionCheckFails tests the situation where an action's
|
||||||
|
// check returns a recoverable error, forcing the plan to execute the fallback action.
|
||||||
|
func TestRunPlanActionCheckFails(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
|
||||||
|
|
||||||
|
action1 := &mockAction{checkErr: plan.CheckFailf("action check failed")}
|
||||||
|
action2 := &mockAction{}
|
||||||
|
|
||||||
|
p := &plan.Plan{
|
||||||
|
Actions: []plan.ActionSet{{
|
||||||
|
Paths: []string{"somepath"},
|
||||||
|
Actions: []plan.Action{
|
||||||
|
action1,
|
||||||
|
action2,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
err := p.Execute(setup)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
assert.Equal("", action1.calledWith) // First action was not run.
|
||||||
|
assert.Equal("somepath", action2.calledWith) // Second action was run.
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunPlanNoFallbacks tests the case where an action's check fails,
|
||||||
|
// but there are not more fallback actions for that path.
|
||||||
|
func TestRunPlanNoFallbacks(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
|
||||||
|
|
||||||
|
action1 := &mockAction{checkErr: plan.CheckFailf("fail")}
|
||||||
|
action2 := &mockAction{checkErr: plan.CheckFailf("fail")}
|
||||||
|
|
||||||
|
p := &plan.Plan{
|
||||||
|
Actions: []plan.ActionSet{{
|
||||||
|
Paths: []string{"somepath"},
|
||||||
|
Actions: []plan.Action{
|
||||||
|
action1,
|
||||||
|
action2,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
err := p.Execute(setup)
|
||||||
|
assert.Nil(err)
|
||||||
|
|
||||||
|
// both actions were not executed.
|
||||||
|
assert.Equal("", action1.calledWith)
|
||||||
|
assert.Equal("", action2.calledWith)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunPlanCheckError tests the scenario where a plan check fails with
|
||||||
|
// an unexpected error. Plan execution is aborted.
|
||||||
|
func TestRunPlanCheckError(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
|
||||||
|
|
||||||
|
preCheck := &mockCheck{returnErr: fmt.Errorf("fail")}
|
||||||
|
action1 := &mockAction{}
|
||||||
|
action2 := &mockAction{}
|
||||||
|
|
||||||
|
p := &plan.Plan{
|
||||||
|
Checks: []plan.Check{preCheck},
|
||||||
|
Actions: []plan.ActionSet{{
|
||||||
|
Paths: []string{"somepath"},
|
||||||
|
Actions: []plan.Action{
|
||||||
|
action1,
|
||||||
|
action2,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
err := p.Execute(setup)
|
||||||
|
assert.EqualError(err, "failed check: fail")
|
||||||
|
|
||||||
|
assert.Equal("", preCheck.calledWith)
|
||||||
|
// Actions were not run.
|
||||||
|
assert.Equal("", action1.calledWith)
|
||||||
|
assert.Equal("", action2.calledWith)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunPlanActionError tests the scenario where an action fails,
|
||||||
|
// aborting the whole sync process.
|
||||||
|
func TestRunPlanActionError(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
|
||||||
|
setup := plan.Setup{} // mocked actions and checks won't be accessing the setup.
|
||||||
|
|
||||||
|
preCheck := &mockCheck{}
|
||||||
|
action1 := &mockAction{runErr: fmt.Errorf("fail")}
|
||||||
|
action2 := &mockAction{}
|
||||||
|
|
||||||
|
p := &plan.Plan{
|
||||||
|
Checks: []plan.Check{preCheck},
|
||||||
|
Actions: []plan.ActionSet{{
|
||||||
|
Paths: []string{"somepath"},
|
||||||
|
Actions: []plan.Action{
|
||||||
|
action1,
|
||||||
|
action2,
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
err := p.Execute(setup)
|
||||||
|
assert.EqualError(err, "action failed: fail")
|
||||||
|
|
||||||
|
assert.Equal("", preCheck.calledWith)
|
||||||
|
assert.Equal("somepath", action1.calledWith)
|
||||||
|
assert.Equal("", action2.calledWith) // second action was not called.
|
||||||
|
}
|
||||||
80
build/sync/plan/setup.go
Normal file
80
build/sync/plan/setup.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package plan
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
git "github.com/go-git/go-git/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RepoID identifies a repository - either plugin or template.
|
||||||
|
type RepoID string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SourceRepo is the id of the template repository (source).
|
||||||
|
SourceRepo RepoID = "source"
|
||||||
|
// TargetRepo is the id of the plugin repository (target).
|
||||||
|
TargetRepo RepoID = "target"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setup contains information about both parties
|
||||||
|
// in the sync: the plugin repository being updated
|
||||||
|
// and the source of the update - the template repo.
|
||||||
|
type Setup struct {
|
||||||
|
Source RepoSetup
|
||||||
|
Target RepoSetup
|
||||||
|
VerboseLogging bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logf logs the provided message.
|
||||||
|
// If verbose output is not enabled, the message will not be printed.
|
||||||
|
func (c Setup) Logf(tpl string, args ...interface{}) {
|
||||||
|
if c.VerboseLogging {
|
||||||
|
fmt.Fprintf(os.Stderr, tpl+"\n", args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogErrorf logs the provided error message.
|
||||||
|
func (c Setup) LogErrorf(tpl string, args ...interface{}) {
|
||||||
|
fmt.Fprintf(os.Stderr, tpl+"\n", args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepo is a helper to get the required repo setup.
|
||||||
|
// If the target parameter is not one of "plugin" or "template",
|
||||||
|
// the function panics.
|
||||||
|
func (c Setup) GetRepo(r RepoID) RepoSetup {
|
||||||
|
switch r {
|
||||||
|
case TargetRepo:
|
||||||
|
return c.Target
|
||||||
|
case SourceRepo:
|
||||||
|
return c.Source
|
||||||
|
default:
|
||||||
|
panic(fmt.Sprintf("cannot get repository setup %q", r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathInRepo returns the full path of a file in the specified repository.
|
||||||
|
func (c Setup) PathInRepo(repo RepoID, path string) string {
|
||||||
|
r := c.GetRepo(repo)
|
||||||
|
return filepath.Join(r.Path, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoSetup contains relevant information
|
||||||
|
// about a single repository (either source or target).
|
||||||
|
type RepoSetup struct {
|
||||||
|
Git *git.Repository
|
||||||
|
Path string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRepoSetup returns the repository setup for the specified path.
|
||||||
|
func GetRepoSetup(path string) (RepoSetup, error) {
|
||||||
|
repo, err := git.PlainOpen(path)
|
||||||
|
if err != nil {
|
||||||
|
return RepoSetup{}, fmt.Errorf("failed to access git repository at %q: %v", path, err)
|
||||||
|
}
|
||||||
|
return RepoSetup{
|
||||||
|
Git: repo,
|
||||||
|
Path: path,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
1
build/sync/plan/testdata/a
vendored
Normal file
1
build/sync/plan/testdata/a
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
a
|
||||||
1
build/sync/plan/testdata/b/c
vendored
Normal file
1
build/sync/plan/testdata/b/c
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
c
|
||||||
13
go.mod
Normal file
13
go.mod
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
module github.com/larkox/mattermost-plugin-badges
|
||||||
|
|
||||||
|
go 1.12
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gorilla/mux v1.8.0
|
||||||
|
github.com/mattermost/mattermost-plugin-api v0.0.14
|
||||||
|
github.com/mattermost/mattermost-server/v5 v5.3.2-0.20210422214809-ff657bfdef24
|
||||||
|
github.com/pkg/errors v0.9.1
|
||||||
|
github.com/spf13/pflag v1.0.5
|
||||||
|
github.com/stretchr/testify v1.7.0
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
|
||||||
|
)
|
||||||
33
plugin.json
Normal file
33
plugin.json
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"id": "com.mattermost.badges",
|
||||||
|
"name": "Badges for Mattermost",
|
||||||
|
"description": "This plugin add badges support to Mattermost.",
|
||||||
|
"homepage_url": "https://github.com/larkox/mattermost-plugin-badges",
|
||||||
|
"support_url": "https://github.com/larkox/mattermost-plugin-badges/issues",
|
||||||
|
"release_notes_url": "https://github.com/larkox/mattermost-plugin-badges/releases/tag/v0.2.1",
|
||||||
|
"icon_path": "assets/starter-template-icon.svg",
|
||||||
|
"version": "0.2.1",
|
||||||
|
"min_server_version": "5.12.0",
|
||||||
|
"server": {
|
||||||
|
"executables": {
|
||||||
|
"linux-amd64": "server/dist/plugin-linux-amd64",
|
||||||
|
"darwin-amd64": "server/dist/plugin-darwin-amd64",
|
||||||
|
"windows-amd64": "server/dist/plugin-windows-amd64.exe"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"webapp": {
|
||||||
|
"bundle_path": "webapp/dist/main.js"
|
||||||
|
},
|
||||||
|
"settings_schema": {
|
||||||
|
"header": "",
|
||||||
|
"footer": "",
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"key": "BadgesAdmin",
|
||||||
|
"display_name": "Badges admin:",
|
||||||
|
"type": "text",
|
||||||
|
"help_text": "This user will be considered as an admin for the badges plugin. They can create types, and modify and grant any badge."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/app-bar-icon.png
Normal file
BIN
public/app-bar-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
1
public/hello.html
Normal file
1
public/hello.html
Normal file
@ -0,0 +1 @@
|
|||||||
|
Hello from the static files public folder for the com.mattermost.plugin-starter-template plugin!
|
||||||
2
server/.gitignore
vendored
Normal file
2
server/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
coverage.txt
|
||||||
|
dist
|
||||||
1047
server/api.go
Normal file
1047
server/api.go
Normal file
File diff suppressed because it is too large
Load Diff
887
server/command.go
Normal file
887
server/command.go
Normal file
@ -0,0 +1,887 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
|
||||||
|
commandparser "github.com/larkox/mattermost-plugin-badges/server/command_parser"
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
"github.com/mattermost/mattermost-server/v5/plugin"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getHelp() string {
|
||||||
|
return `Available Commands:
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) getCommand() *model.Command {
|
||||||
|
return &model.Command{
|
||||||
|
Trigger: "badges",
|
||||||
|
DisplayName: "Badges Bot",
|
||||||
|
Description: "Badges",
|
||||||
|
AutoComplete: true,
|
||||||
|
AutoCompleteDesc: "Available commands:",
|
||||||
|
AutoCompleteHint: "[command]",
|
||||||
|
AutocompleteData: p.getAutocompleteData(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) postCommandResponse(args *model.CommandArgs, text string) {
|
||||||
|
post := &model.Post{
|
||||||
|
UserId: p.BotUserID,
|
||||||
|
ChannelId: args.ChannelId,
|
||||||
|
Message: text,
|
||||||
|
}
|
||||||
|
p.mm.Post.SendEphemeralPost(args.UserId, post)
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandError(text string) (bool, *model.CommandResponse, error) {
|
||||||
|
return true, &model.CommandResponse{}, errors.New(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecuteCommand executes a given command and returns a command response.
|
||||||
|
func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) {
|
||||||
|
stringArgs := commandparser.Parse(args.Command)
|
||||||
|
lengthOfArgs := len(stringArgs)
|
||||||
|
restOfArgs := []string{}
|
||||||
|
|
||||||
|
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
|
||||||
|
if lengthOfArgs == 1 {
|
||||||
|
p.postCommandResponse(args, getHelp())
|
||||||
|
return &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
command := stringArgs[1]
|
||||||
|
if lengthOfArgs > 2 {
|
||||||
|
restOfArgs = stringArgs[2:]
|
||||||
|
}
|
||||||
|
switch command {
|
||||||
|
case "test-clean":
|
||||||
|
handler = p.runClean
|
||||||
|
case "grant":
|
||||||
|
handler = p.runGrant
|
||||||
|
case "edit":
|
||||||
|
handler = p.runEdit
|
||||||
|
case "create":
|
||||||
|
handler = p.runCreate
|
||||||
|
case "subscription":
|
||||||
|
handler = p.runSubscription
|
||||||
|
default:
|
||||||
|
p.postCommandResponse(args, getHelp())
|
||||||
|
return &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
isUserError, resp, err := handler(restOfArgs, args)
|
||||||
|
if err != nil {
|
||||||
|
if isUserError {
|
||||||
|
p.postCommandResponse(args, fmt.Sprintf("__Error: %s__", err.Error()))
|
||||||
|
} else {
|
||||||
|
p.mm.Log.Error(err.Error())
|
||||||
|
p.postCommandResponse(args, "An unknown error occurred. Please talk to your system administrator for help.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp != nil {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runClean(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
user, err := p.mm.User.Get(extra.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return false, &model.CommandResponse{Text: "Cannot get user."}, nil
|
||||||
|
}
|
||||||
|
if !user.IsSystemAdmin() {
|
||||||
|
return false, &model.CommandResponse{Text: "Only a system admin can clean the badges database."}, nil
|
||||||
|
}
|
||||||
|
_ = p.mm.KV.DeleteAll()
|
||||||
|
return false, &model.CommandResponse{Text: "Clean"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runCreate(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
lengthOfArgs := len(args)
|
||||||
|
restOfArgs := []string{}
|
||||||
|
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
|
||||||
|
if lengthOfArgs == 0 {
|
||||||
|
return false, &model.CommandResponse{Text: "Specify what you want to create."}, nil
|
||||||
|
}
|
||||||
|
command := args[0]
|
||||||
|
if lengthOfArgs > 1 {
|
||||||
|
restOfArgs = args[1:]
|
||||||
|
}
|
||||||
|
switch command {
|
||||||
|
case "badge":
|
||||||
|
handler = p.runCreateBadge
|
||||||
|
case "type":
|
||||||
|
handler = p.runCreateType
|
||||||
|
default:
|
||||||
|
return false, &model.CommandResponse{Text: "You can create either badge or type"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(restOfArgs, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runCreateBadge(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
u, err := p.mm.User.Get(extra.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSuggestions, err := p.filterCreateBadgeTypes(u)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
typeOptions := []*model.PostActionOptions{}
|
||||||
|
for _, typeSuggestion := range typeSuggestions {
|
||||||
|
id := string(typeSuggestion.ID)
|
||||||
|
typeOptions = append(typeOptions, &model.PostActionOptions{Text: typeSuggestion.Name, Value: id})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(typeOptions) == 0 {
|
||||||
|
return commandError("You cannot create badges from any type.")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
|
||||||
|
TriggerId: extra.TriggerId,
|
||||||
|
URL: p.getDialogURL() + DialogPathCreateBadge,
|
||||||
|
Dialog: model.Dialog{
|
||||||
|
Title: "Create badge",
|
||||||
|
SubmitLabel: "Create",
|
||||||
|
Elements: []model.DialogElement{
|
||||||
|
{
|
||||||
|
DisplayName: "Name",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldBadgeName,
|
||||||
|
MaxLength: badgesmodel.NameMaxLength,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Description",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldBadgeDescription,
|
||||||
|
MaxLength: badgesmodel.DescriptionMaxLength,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Image",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldBadgeImage,
|
||||||
|
HelpText: "Insert a emoticon name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Type",
|
||||||
|
Type: "select",
|
||||||
|
Name: DialogFieldBadgeType,
|
||||||
|
Options: typeOptions,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Multiple",
|
||||||
|
Type: "bool",
|
||||||
|
Name: DialogFieldBadgeMultiple,
|
||||||
|
HelpText: "Whether the badge can be granted multiple times",
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runEdit(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
lengthOfArgs := len(args)
|
||||||
|
restOfArgs := []string{}
|
||||||
|
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
|
||||||
|
if lengthOfArgs == 0 {
|
||||||
|
return false, &model.CommandResponse{Text: "Specify what you want to create."}, nil
|
||||||
|
}
|
||||||
|
command := args[0]
|
||||||
|
if lengthOfArgs > 1 {
|
||||||
|
restOfArgs = args[1:]
|
||||||
|
}
|
||||||
|
switch command {
|
||||||
|
case "badge":
|
||||||
|
handler = p.runEditBadge
|
||||||
|
case "type":
|
||||||
|
handler = p.runEditType
|
||||||
|
default:
|
||||||
|
return false, &model.CommandResponse{Text: "You can create either badge or type"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(restOfArgs, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runEditBadge(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
u, err := p.mm.User.Get(extra.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
var badgeIDStr string
|
||||||
|
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||||
|
fs.StringVar(&badgeIDStr, "id", "", "ID of the badge")
|
||||||
|
if err = fs.Parse(args); err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if badgeIDStr == "" {
|
||||||
|
return commandError("You must set the badge ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
badge, err := p.store.GetBadge(badgesmodel.BadgeID(badgeIDStr))
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canEditBadge(u, p.badgeAdminUserID, badge) {
|
||||||
|
return commandError("you cannot edit this badge")
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSuggestions, err := p.filterCreateBadgeTypes(u)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
typeOptions := []*model.PostActionOptions{}
|
||||||
|
for _, typeSuggestion := range typeSuggestions {
|
||||||
|
id := string(typeSuggestion.ID)
|
||||||
|
typeOptions = append(typeOptions, &model.PostActionOptions{Text: typeSuggestion.Name, Value: id})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(typeOptions) == 0 {
|
||||||
|
return commandError("You cannot create badges from any type.")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
|
||||||
|
TriggerId: extra.TriggerId,
|
||||||
|
URL: p.getDialogURL() + DialogPathEditBadge,
|
||||||
|
Dialog: model.Dialog{
|
||||||
|
Title: "Create badge",
|
||||||
|
SubmitLabel: "Edit",
|
||||||
|
State: string(badge.ID),
|
||||||
|
Elements: []model.DialogElement{
|
||||||
|
{
|
||||||
|
DisplayName: "Name",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldBadgeName,
|
||||||
|
MaxLength: badgesmodel.NameMaxLength,
|
||||||
|
Default: badge.Name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Description",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldBadgeDescription,
|
||||||
|
MaxLength: badgesmodel.DescriptionMaxLength,
|
||||||
|
Default: badge.Description,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Image",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldBadgeImage,
|
||||||
|
HelpText: "Insert a emoticon name",
|
||||||
|
Default: badge.Image,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Type",
|
||||||
|
Type: "select",
|
||||||
|
Name: DialogFieldBadgeType,
|
||||||
|
Options: typeOptions,
|
||||||
|
Default: string(badge.Type),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Multiple",
|
||||||
|
Type: "bool",
|
||||||
|
Name: DialogFieldBadgeMultiple,
|
||||||
|
HelpText: "Whether the badge can be granted multiple times",
|
||||||
|
Optional: true,
|
||||||
|
Default: getBooleanString(badge.Multiple),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Delete badge",
|
||||||
|
Type: "bool",
|
||||||
|
Name: DialogFieldBadgeDelete,
|
||||||
|
HelpText: "WARNING: Checking this will remove this badge permanently.",
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runEditType(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
u, err := p.mm.User.Get(extra.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canCreateType(u, p.badgeAdminUserID, false) {
|
||||||
|
return commandError("You have no permissions to edit a badge type.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var badgeTypeStr string
|
||||||
|
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||||
|
fs.StringVar(&badgeTypeStr, "type", "", "ID of the type")
|
||||||
|
if err = fs.Parse(args); err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if badgeTypeStr == "" {
|
||||||
|
return commandError("You must provide a type id")
|
||||||
|
}
|
||||||
|
|
||||||
|
typeDefinition, err := p.store.GetType(badgesmodel.BadgeType(badgeTypeStr))
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canEditType(u, p.badgeAdminUserID, typeDefinition) {
|
||||||
|
return commandError("you cannot edit this type")
|
||||||
|
}
|
||||||
|
|
||||||
|
canGrantAllowList := ""
|
||||||
|
for uID, allowed := range typeDefinition.CanGrant.AllowList {
|
||||||
|
if !allowed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var allowedUser *model.User
|
||||||
|
allowedUser, err = p.mm.User.Get(uID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if canGrantAllowList == "" {
|
||||||
|
canGrantAllowList += allowedUser.Username
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
canGrantAllowList += ", " + allowedUser.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
canCreateAllowList := ""
|
||||||
|
for uID, allowed := range typeDefinition.CanCreate.AllowList {
|
||||||
|
if !allowed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var allowedUser *model.User
|
||||||
|
allowedUser, err = p.mm.User.Get(uID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if canCreateAllowList == "" {
|
||||||
|
canCreateAllowList += allowedUser.Username
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
canCreateAllowList += ", " + allowedUser.Username
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
|
||||||
|
TriggerId: extra.TriggerId,
|
||||||
|
URL: p.getDialogURL() + DialogPathEditType,
|
||||||
|
Dialog: model.Dialog{
|
||||||
|
Title: "Edit type",
|
||||||
|
SubmitLabel: "Edit",
|
||||||
|
State: badgeTypeStr,
|
||||||
|
Elements: []model.DialogElement{
|
||||||
|
{
|
||||||
|
DisplayName: "Name",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldTypeName,
|
||||||
|
MaxLength: badgesmodel.NameMaxLength,
|
||||||
|
Default: typeDefinition.Name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Everyone can create badge",
|
||||||
|
Type: "bool",
|
||||||
|
Name: DialogFieldTypeEveryoneCanCreate,
|
||||||
|
HelpText: "Whether any user can create a badge of this type",
|
||||||
|
Optional: true,
|
||||||
|
Default: getBooleanString(typeDefinition.CanCreate.Everyone),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Can create allowlist",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldTypeAllowlistCanCreate,
|
||||||
|
HelpText: "Fill the usernames separated by comma (,) of the people that can create badges of this type.",
|
||||||
|
Placeholder: "user-1, user-2, user-3",
|
||||||
|
Optional: true,
|
||||||
|
Default: canCreateAllowList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Everyone can grant badge",
|
||||||
|
Type: "bool",
|
||||||
|
Name: DialogFieldTypeEveryoneCanGrant,
|
||||||
|
HelpText: "Whether any user can grant a badge of this type",
|
||||||
|
Optional: true,
|
||||||
|
Default: getBooleanString(typeDefinition.CanGrant.Everyone),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Can grant allowlist",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldTypeAllowlistCanGrant,
|
||||||
|
HelpText: "Fill the usernames separated by comma (,) of the people that can grant badges of this type.",
|
||||||
|
Placeholder: "user-1, user-2, user-3",
|
||||||
|
Optional: true,
|
||||||
|
Default: canGrantAllowList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Remove type",
|
||||||
|
Type: "bool",
|
||||||
|
Name: DialogFieldTypeDelete,
|
||||||
|
HelpText: "WARNING: checking this will remove this type and all associated badges permanently.",
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runCreateType(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
u, err := p.mm.User.Get(extra.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canCreateType(u, p.badgeAdminUserID, false) {
|
||||||
|
return commandError("You have no permissions to create a badge type.")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
|
||||||
|
TriggerId: extra.TriggerId,
|
||||||
|
URL: p.getDialogURL() + DialogPathCreateType,
|
||||||
|
Dialog: model.Dialog{
|
||||||
|
Title: "Create type",
|
||||||
|
SubmitLabel: "Create",
|
||||||
|
Elements: []model.DialogElement{
|
||||||
|
{
|
||||||
|
DisplayName: "Name",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldTypeName,
|
||||||
|
MaxLength: badgesmodel.NameMaxLength,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Everyone can create badge",
|
||||||
|
Type: "bool",
|
||||||
|
Name: DialogFieldTypeEveryoneCanCreate,
|
||||||
|
HelpText: "Whether any user can create a badge of this type",
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Can create allowlist",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldTypeAllowlistCanCreate,
|
||||||
|
HelpText: "Fill the usernames separated by comma (,) of the people that can create badges of this type.",
|
||||||
|
Placeholder: "user-1, user-2, user-3",
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Everyone can grant badge",
|
||||||
|
Type: "bool",
|
||||||
|
Name: DialogFieldTypeEveryoneCanGrant,
|
||||||
|
HelpText: "Whether any user can grant a badge of this type",
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
DisplayName: "Can grant allowlist",
|
||||||
|
Type: "text",
|
||||||
|
Name: DialogFieldTypeAllowlistCanGrant,
|
||||||
|
HelpText: "Fill the usernames separated by comma (,) of the people that can grant badges of this type.",
|
||||||
|
Placeholder: "user-1, user-2, user-3",
|
||||||
|
Optional: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runGrant(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
badgeStr := ""
|
||||||
|
username := ""
|
||||||
|
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||||
|
fs.StringVar(&badgeStr, "badge", "", "ID of the badge")
|
||||||
|
fs.StringVar(&username, "user", "", "Username to grant to")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if username != "" && badgeStr != "" {
|
||||||
|
if username[0] == '@' {
|
||||||
|
username = username[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
granter, err := p.mm.User.Get(extra.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
badge, err := p.store.GetBadge(badgesmodel.BadgeID(badgeStr))
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeType, err := p.store.GetType(badge.Type)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canGrantBadge(granter, p.badgeAdminUserID, badge, badgeType) {
|
||||||
|
return commandError("you have no permissions to grant this badge")
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := p.mm.User.GetByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldNotify, err := p.store.GrantBadge(badgesmodel.BadgeID(badgeStr), user.Id, extra.UserId, "")
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldNotify {
|
||||||
|
p.notifyGrant(badgesmodel.BadgeID(badgeStr), extra.UserId, user, false, "", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.postCommandResponse(extra, "Granted")
|
||||||
|
return false, &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
elements := []model.DialogElement{}
|
||||||
|
|
||||||
|
stateText := ""
|
||||||
|
introductionText := ""
|
||||||
|
if username != "" {
|
||||||
|
if username[0] == '@' {
|
||||||
|
username = username[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := p.mm.User.GetByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
introductionText = "Grant badge to @" + username
|
||||||
|
stateText = user.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
if stateText == "" {
|
||||||
|
elements = append(elements, model.DialogElement{
|
||||||
|
DisplayName: "User",
|
||||||
|
Type: "select",
|
||||||
|
Name: DialogFieldUser,
|
||||||
|
DataSource: "users",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
actingUser, err := p.mm.User.Get(extra.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
options := []*model.PostActionOptions{}
|
||||||
|
grantableBadges, err := p.filterGrantBadges(actingUser)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
for _, badge := range grantableBadges {
|
||||||
|
options = append(options, &model.PostActionOptions{Text: badge.Name, Value: string(badge.ID)})
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeElement := model.DialogElement{
|
||||||
|
DisplayName: "Badge",
|
||||||
|
Type: "select",
|
||||||
|
Name: DialogFieldBadge,
|
||||||
|
Options: options,
|
||||||
|
}
|
||||||
|
|
||||||
|
if badgeStr != "" {
|
||||||
|
found := false
|
||||||
|
for _, badge := range grantableBadges {
|
||||||
|
if badgeStr == string(badge.ID) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return commandError("You cannot grant that badge")
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeElement.Default = badgeStr
|
||||||
|
}
|
||||||
|
|
||||||
|
elements = append(elements, badgeElement)
|
||||||
|
|
||||||
|
elements = append(elements, model.DialogElement{
|
||||||
|
DisplayName: "Reason",
|
||||||
|
Name: DialogFieldGrantReason,
|
||||||
|
Optional: true,
|
||||||
|
HelpText: "Reason why you are granting this badge. This will be seen by the user, and wherever this grant notification is shown (e.g. subscriptions).",
|
||||||
|
Type: "text",
|
||||||
|
})
|
||||||
|
|
||||||
|
elements = append(elements, model.DialogElement{
|
||||||
|
DisplayName: "Notify on this channel",
|
||||||
|
Name: DialogFieldNotifyHere,
|
||||||
|
Type: "bool",
|
||||||
|
HelpText: "If you mark this, the bot will send a message to this channel notifying that you granted this badge to this person.",
|
||||||
|
Optional: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
|
||||||
|
TriggerId: extra.TriggerId,
|
||||||
|
URL: p.getDialogURL() + DialogPathGrant,
|
||||||
|
Dialog: model.Dialog{
|
||||||
|
Title: "Grant badge",
|
||||||
|
IntroductionText: introductionText,
|
||||||
|
SubmitLabel: "Grant",
|
||||||
|
Elements: elements,
|
||||||
|
State: stateText,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runSubscription(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
lengthOfArgs := len(args)
|
||||||
|
restOfArgs := []string{}
|
||||||
|
var handler func([]string, *model.CommandArgs) (bool, *model.CommandResponse, error)
|
||||||
|
if lengthOfArgs == 0 {
|
||||||
|
return false, &model.CommandResponse{Text: "Specify what you want to do."}, nil
|
||||||
|
}
|
||||||
|
command := args[0]
|
||||||
|
if lengthOfArgs > 1 {
|
||||||
|
restOfArgs = args[1:]
|
||||||
|
}
|
||||||
|
switch command {
|
||||||
|
case "create":
|
||||||
|
handler = p.runCreateSubscription
|
||||||
|
case "remove":
|
||||||
|
handler = p.runDeleteSubscription
|
||||||
|
default:
|
||||||
|
return false, &model.CommandResponse{Text: "You can either create or delete subscriptions"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return handler(restOfArgs, extra)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runCreateSubscription(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
typeStr := ""
|
||||||
|
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||||
|
fs.StringVar(&typeStr, "type", "", "ID of the badge")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
actingUser, err := p.mm.User.Get(extra.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
|
||||||
|
return commandError("You cannot create subscriptions")
|
||||||
|
}
|
||||||
|
|
||||||
|
if typeStr != "" {
|
||||||
|
|
||||||
|
err = p.store.AddSubscription(badgesmodel.BadgeType(typeStr), extra.ChannelId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
p.postCommandResponse(extra, "Granted")
|
||||||
|
return false, &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
options := []*model.PostActionOptions{}
|
||||||
|
typesDefinitions, err := p.filterEditTypes(actingUser)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
for _, typeDefinition := range typesDefinitions {
|
||||||
|
options = append(options, &model.PostActionOptions{Text: typeDefinition.Name, Value: string(typeDefinition.ID)})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
|
||||||
|
TriggerId: extra.TriggerId,
|
||||||
|
URL: p.getDialogURL() + DialogPathCreateSubscription,
|
||||||
|
Dialog: model.Dialog{
|
||||||
|
Title: "Create subscription",
|
||||||
|
IntroductionText: "Introduce the badge type you want to subscribe to this channel.",
|
||||||
|
SubmitLabel: "Add",
|
||||||
|
Elements: []model.DialogElement{
|
||||||
|
{
|
||||||
|
DisplayName: "Type",
|
||||||
|
Type: "select",
|
||||||
|
Name: DialogFieldBadgeType,
|
||||||
|
Options: options,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) runDeleteSubscription(args []string, extra *model.CommandArgs) (bool, *model.CommandResponse, error) {
|
||||||
|
typeStr := ""
|
||||||
|
fs := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||||
|
fs.StringVar(&typeStr, "type", "", "ID of the badge")
|
||||||
|
if err := fs.Parse(args); err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
actingUser, err := p.mm.User.Get(extra.UserId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canCreateSubscription(actingUser, p.badgeAdminUserID, extra.ChannelId) {
|
||||||
|
return commandError("You cannot create subscriptions")
|
||||||
|
}
|
||||||
|
|
||||||
|
if typeStr != "" {
|
||||||
|
err = p.store.RemoveSubscriptions(badgesmodel.BadgeType(typeStr), extra.ChannelId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
p.postCommandResponse(extra, "Removed")
|
||||||
|
return false, &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
options := []*model.PostActionOptions{}
|
||||||
|
typesDefinitions, err := p.store.GetChannelSubscriptions(extra.ChannelId)
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
for _, typeDefinition := range typesDefinitions {
|
||||||
|
options = append(options, &model.PostActionOptions{Text: typeDefinition.Name, Value: string(typeDefinition.ID)})
|
||||||
|
}
|
||||||
|
|
||||||
|
err = p.mm.Frontend.OpenInteractiveDialog(model.OpenDialogRequest{
|
||||||
|
TriggerId: extra.TriggerId,
|
||||||
|
URL: p.getDialogURL() + DialogPathDeleteSubscription,
|
||||||
|
Dialog: model.Dialog{
|
||||||
|
Title: "Delete subscription",
|
||||||
|
IntroductionText: "Introduce the badge type you want to remove from this channel.",
|
||||||
|
SubmitLabel: "Remove",
|
||||||
|
Elements: []model.DialogElement{
|
||||||
|
{
|
||||||
|
DisplayName: "Type",
|
||||||
|
Type: "select",
|
||||||
|
Name: DialogFieldBadgeType,
|
||||||
|
Options: options,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return commandError(err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, &model.CommandResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) getAutocompleteData() *model.AutocompleteData {
|
||||||
|
badges := model.NewAutocompleteData("badges", "[command]", "Available commands: grant")
|
||||||
|
|
||||||
|
grant := model.NewAutocompleteData("grant", "--user @username --badge id", "Grant a badge to a user")
|
||||||
|
grant.AddNamedDynamicListArgument("badge", "--badge badgeID", getAutocompletePath(AutocompletePathBadgeSuggestions), true)
|
||||||
|
grant.AddNamedTextArgument("user", "User to grant the badge to", "--user @username", "", true)
|
||||||
|
badges.AddCommand(grant)
|
||||||
|
|
||||||
|
create := model.NewAutocompleteData("create", "badge | type", "Create a badge or a type")
|
||||||
|
|
||||||
|
badge := model.NewAutocompleteData(
|
||||||
|
"badge",
|
||||||
|
"",
|
||||||
|
"Create a badge",
|
||||||
|
)
|
||||||
|
create.AddCommand(badge)
|
||||||
|
|
||||||
|
createType := model.NewAutocompleteData(
|
||||||
|
"type",
|
||||||
|
"",
|
||||||
|
"Create a badge type",
|
||||||
|
)
|
||||||
|
create.AddCommand(createType)
|
||||||
|
|
||||||
|
badges.AddCommand(create)
|
||||||
|
|
||||||
|
edit := model.NewAutocompleteData("edit", "badge | type", "Edit a badge or a type")
|
||||||
|
|
||||||
|
editBadge := model.NewAutocompleteData(
|
||||||
|
"badge",
|
||||||
|
"",
|
||||||
|
"Edit a badge",
|
||||||
|
)
|
||||||
|
editBadge.AddNamedDynamicListArgument("id", "--id badgeID", getAutocompletePath(AutocompletePathEditBadgeSuggestions), true)
|
||||||
|
edit.AddCommand(editBadge)
|
||||||
|
|
||||||
|
editType := model.NewAutocompleteData(
|
||||||
|
"type",
|
||||||
|
"",
|
||||||
|
"Edit a badge type",
|
||||||
|
)
|
||||||
|
editType.AddNamedDynamicListArgument("type", "--type typeID", getAutocompletePath(AutocompletePathEditTypeSuggestions), true)
|
||||||
|
edit.AddCommand(editType)
|
||||||
|
|
||||||
|
badges.AddCommand(edit)
|
||||||
|
|
||||||
|
subscription := model.NewAutocompleteData("subscription", "create | remove", "Manage this channel subscriptions")
|
||||||
|
|
||||||
|
createSubscription := model.NewAutocompleteData(
|
||||||
|
"create",
|
||||||
|
"",
|
||||||
|
"Create a subscription",
|
||||||
|
)
|
||||||
|
subscription.AddCommand(createSubscription)
|
||||||
|
|
||||||
|
deleteSubscription := model.NewAutocompleteData(
|
||||||
|
"remove",
|
||||||
|
"",
|
||||||
|
"Remove a subscription",
|
||||||
|
)
|
||||||
|
subscription.AddCommand(deleteSubscription)
|
||||||
|
|
||||||
|
badges.AddCommand(subscription)
|
||||||
|
|
||||||
|
return badges
|
||||||
|
}
|
||||||
|
|
||||||
|
func getAutocompletePath(path string) string {
|
||||||
|
return "plugins/" + manifest.Id + AutocompletePath + path
|
||||||
|
}
|
||||||
76
server/command_parser/command_parser.go
Normal file
76
server/command_parser/command_parser.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package commandparser
|
||||||
|
|
||||||
|
import "unicode"
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateLooking = "looking"
|
||||||
|
stateQuoted = "quoted"
|
||||||
|
stateEscape = "escape"
|
||||||
|
stateFilling = "filling"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Parse(in string) []string {
|
||||||
|
nextArg := ""
|
||||||
|
out := []string{}
|
||||||
|
state := stateLooking
|
||||||
|
for _, c := range in {
|
||||||
|
switch state {
|
||||||
|
case stateLooking:
|
||||||
|
if unicode.IsSpace(c) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c == '"' {
|
||||||
|
state = stateQuoted
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
state = stateFilling
|
||||||
|
nextArg += string(c)
|
||||||
|
case stateFilling:
|
||||||
|
if unicode.IsSpace(c) {
|
||||||
|
state = stateLooking
|
||||||
|
out = append(out, nextArg)
|
||||||
|
nextArg = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nextArg += string(c)
|
||||||
|
case stateQuoted:
|
||||||
|
if c == '"' {
|
||||||
|
state = stateLooking
|
||||||
|
out = append(out, nextArg)
|
||||||
|
nextArg = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if c == '\\' {
|
||||||
|
state = stateEscape
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
nextArg += string(c)
|
||||||
|
case stateEscape:
|
||||||
|
if c == '"' {
|
||||||
|
state = stateQuoted
|
||||||
|
nextArg += string(c)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not escaping quotes, so we readd the backslash
|
||||||
|
nextArg += string('\\')
|
||||||
|
nextArg += string(c)
|
||||||
|
default:
|
||||||
|
panic("unexpected state")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == stateEscape {
|
||||||
|
nextArg += string('\\')
|
||||||
|
}
|
||||||
|
|
||||||
|
if state == stateFilling {
|
||||||
|
out = append(out, nextArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
93
server/configuration.go
Normal file
93
server/configuration.go
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// configuration captures the plugin's external configuration as exposed in the Mattermost server
|
||||||
|
// configuration, as well as values computed from the configuration. Any public fields will be
|
||||||
|
// deserialized from the Mattermost server configuration in OnConfigurationChange.
|
||||||
|
//
|
||||||
|
// As plugins are inherently concurrent (hooks being called asynchronously), and the plugin
|
||||||
|
// configuration can change at any time, access to the configuration must be synchronized. The
|
||||||
|
// strategy used in this plugin is to guard a pointer to the configuration, and clone the entire
|
||||||
|
// struct whenever it changes. You may replace this with whatever strategy you choose.
|
||||||
|
//
|
||||||
|
// If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep
|
||||||
|
// copy appropriate for your types.
|
||||||
|
type configuration struct {
|
||||||
|
BadgesAdmin string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone shallow copies the configuration. Your implementation may require a deep copy if
|
||||||
|
// your configuration has reference types.
|
||||||
|
func (c *configuration) Clone() *configuration {
|
||||||
|
var clone = *c
|
||||||
|
return &clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// getConfiguration retrieves the active configuration under lock, making it safe to use
|
||||||
|
// concurrently. The active configuration may change underneath the client of this method, but
|
||||||
|
// the struct returned by this API call is considered immutable.
|
||||||
|
func (p *Plugin) getConfiguration() *configuration {
|
||||||
|
p.configurationLock.RLock()
|
||||||
|
defer p.configurationLock.RUnlock()
|
||||||
|
|
||||||
|
if p.configuration == nil {
|
||||||
|
return &configuration{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// setConfiguration replaces the active configuration under lock.
|
||||||
|
//
|
||||||
|
// Do not call setConfiguration while holding the configurationLock, as sync.Mutex is not
|
||||||
|
// reentrant. In particular, avoid using the plugin API entirely, as this may in turn trigger a
|
||||||
|
// hook back into the plugin. If that hook attempts to acquire this lock, a deadlock may occur.
|
||||||
|
//
|
||||||
|
// This method panics if setConfiguration is called with the existing configuration. This almost
|
||||||
|
// certainly means that the configuration was modified without being cloned and may result in
|
||||||
|
// an unsafe access.
|
||||||
|
func (p *Plugin) setConfiguration(configuration *configuration) {
|
||||||
|
p.configurationLock.Lock()
|
||||||
|
defer p.configurationLock.Unlock()
|
||||||
|
|
||||||
|
if configuration != nil && p.configuration == configuration {
|
||||||
|
// Ignore assignment if the configuration struct is empty. Go will optimize the
|
||||||
|
// allocation for same to point at the same memory address, breaking the check
|
||||||
|
// above.
|
||||||
|
if reflect.ValueOf(*configuration).NumField() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("setConfiguration called with the existing configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.configuration = configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnConfigurationChange is invoked when configuration changes may have been made.
|
||||||
|
func (p *Plugin) OnConfigurationChange() error {
|
||||||
|
var configuration = new(configuration)
|
||||||
|
|
||||||
|
// Load the public configuration fields from the Mattermost server configuration.
|
||||||
|
if err := p.API.LoadPluginConfiguration(configuration); err != nil {
|
||||||
|
return errors.Wrap(err, "failed to load plugin configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.badgeAdminUserID = ""
|
||||||
|
if configuration.BadgesAdmin != "" {
|
||||||
|
u, err := p.API.GetUserByUsername(configuration.BadgesAdmin)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "cannot get badge admin user")
|
||||||
|
}
|
||||||
|
p.badgeAdminUserID = u.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
p.setConfiguration(configuration)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
45
server/constants.go
Normal file
45
server/constants.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
const (
|
||||||
|
KVKeyBadges = "badges"
|
||||||
|
KVKeyOwnership = "ownership"
|
||||||
|
KVKeyTypes = "types"
|
||||||
|
KVKeySubscriptions = "subs"
|
||||||
|
|
||||||
|
AutocompletePath = "/autocomplete"
|
||||||
|
AutocompletePathBadgeSuggestions = "/getBadgeSuggestions"
|
||||||
|
AutocompletePathTypeSuggestions = "/getBadgeTypeSuggestions"
|
||||||
|
AutocompletePathEditBadgeSuggestions = "/getEditBadgeSuggestions"
|
||||||
|
AutocompletePathEditTypeSuggestions = "/getEditTypeSuggestions"
|
||||||
|
|
||||||
|
DialogPath = "/dialog"
|
||||||
|
DialogPathCreateBadge = "/createBadge"
|
||||||
|
DialogPathSelectType = "/selectType"
|
||||||
|
DialogPathCreateType = "/createType"
|
||||||
|
DialogPathEditType = "/editType"
|
||||||
|
DialogPathGrant = "/grant"
|
||||||
|
DialogPathSelectBadge = "/selectBadge"
|
||||||
|
DialogPathEditBadge = "/editBadge"
|
||||||
|
DialogPathCreateSubscription = "/createSubscription"
|
||||||
|
DialogPathDeleteSubscription = "/deleteSubscription"
|
||||||
|
|
||||||
|
DialogFieldBadgeName = "name"
|
||||||
|
DialogFieldBadgeMultiple = "multiple"
|
||||||
|
DialogFieldBadgeDescription = "description"
|
||||||
|
DialogFieldBadgeType = "type"
|
||||||
|
DialogFieldBadgeImage = "image"
|
||||||
|
DialogFieldBadgeDelete = "delete"
|
||||||
|
DialogFieldTypeName = "name"
|
||||||
|
DialogFieldTypeEveryoneCanGrant = "everyoneCanGrant"
|
||||||
|
DialogFieldTypeAllowlistCanGrant = "whitelistCanGrant"
|
||||||
|
DialogFieldTypeEveryoneCanCreate = "everyoneCanCreate"
|
||||||
|
DialogFieldTypeAllowlistCanCreate = "whitelistCanCreate"
|
||||||
|
DialogFieldTypeDelete = "delete"
|
||||||
|
DialogFieldUser = "user"
|
||||||
|
DialogFieldBadge = "badge"
|
||||||
|
DialogFieldNotifyHere = "notify_here"
|
||||||
|
DialogFieldGrantReason = "reason"
|
||||||
|
|
||||||
|
TrueString = "true"
|
||||||
|
FalseString = "false"
|
||||||
|
)
|
||||||
9
server/main.go
Normal file
9
server/main.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/mattermost/mattermost-server/v5/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
plugin.ClientMain(&Plugin{})
|
||||||
|
}
|
||||||
54
server/manifest.go
generated
Normal file
54
server/manifest.go
generated
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
// This file is automatically generated. Do not modify it manually.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
var manifest *model.Manifest
|
||||||
|
|
||||||
|
const manifestStr = `
|
||||||
|
{
|
||||||
|
"id": "com.mattermost.badges",
|
||||||
|
"name": "Badges for Mattermost",
|
||||||
|
"description": "This plugin add badges support to Mattermost.",
|
||||||
|
"homepage_url": "https://github.com/larkox/mattermost-plugin-badges",
|
||||||
|
"support_url": "https://github.com/larkox/mattermost-plugin-badges/issues",
|
||||||
|
"release_notes_url": "https://github.com/larkox/mattermost-plugin-badges/releases/tag/v0.2.1",
|
||||||
|
"icon_path": "assets/starter-template-icon.svg",
|
||||||
|
"version": "0.2.1",
|
||||||
|
"min_server_version": "5.12.0",
|
||||||
|
"server": {
|
||||||
|
"executables": {
|
||||||
|
"linux-amd64": "server/dist/plugin-linux-amd64",
|
||||||
|
"darwin-amd64": "server/dist/plugin-darwin-amd64",
|
||||||
|
"windows-amd64": "server/dist/plugin-windows-amd64.exe"
|
||||||
|
},
|
||||||
|
"executable": ""
|
||||||
|
},
|
||||||
|
"webapp": {
|
||||||
|
"bundle_path": "webapp/dist/main.js"
|
||||||
|
},
|
||||||
|
"settings_schema": {
|
||||||
|
"header": "",
|
||||||
|
"footer": "",
|
||||||
|
"settings": [
|
||||||
|
{
|
||||||
|
"key": "BadgesAdmin",
|
||||||
|
"display_name": "Badges admin:",
|
||||||
|
"type": "text",
|
||||||
|
"help_text": "This user will be considered as an admin for the badges plugin. They can create types, and modify and grant any badge.",
|
||||||
|
"placeholder": "",
|
||||||
|
"default": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
manifest = model.ManifestFromJson(strings.NewReader(manifestStr))
|
||||||
|
}
|
||||||
56
server/plugin.go
Normal file
56
server/plugin.go
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
pluginapi "github.com/mattermost/mattermost-plugin-api"
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
"github.com/mattermost/mattermost-server/v5/plugin"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes.
|
||||||
|
type Plugin struct {
|
||||||
|
plugin.MattermostPlugin
|
||||||
|
|
||||||
|
// configurationLock synchronizes access to the configuration.
|
||||||
|
configurationLock sync.RWMutex
|
||||||
|
|
||||||
|
// configuration is the active plugin configuration. Consult getConfiguration and
|
||||||
|
// setConfiguration for usage.
|
||||||
|
configuration *configuration
|
||||||
|
|
||||||
|
mm *pluginapi.Client
|
||||||
|
BotUserID string
|
||||||
|
store Store
|
||||||
|
router *mux.Router
|
||||||
|
badgeAdminUserID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world.
|
||||||
|
func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) {
|
||||||
|
r.Header.Add("Mattermost-Plugin-ID", c.SourcePluginId)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
p.router.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// See https://developers.mattermost.com/extend/plugins/server/reference/
|
||||||
|
func (p *Plugin) OnActivate() error {
|
||||||
|
p.mm = pluginapi.NewClient(p.API)
|
||||||
|
botID, err := p.Helpers.EnsureBot(&model.Bot{
|
||||||
|
Username: "badges",
|
||||||
|
DisplayName: "Badges Bot",
|
||||||
|
Description: "Created by the Badges plugin.",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "failed to ensure badges bot")
|
||||||
|
}
|
||||||
|
p.BotUserID = botID
|
||||||
|
p.store = NewStore(p.API)
|
||||||
|
p.initializeAPI()
|
||||||
|
|
||||||
|
return p.mm.SlashCommand.Register(p.getCommand())
|
||||||
|
}
|
||||||
28
server/plugin_test.go
Normal file
28
server/plugin_test.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServeHTTP(t *testing.T) {
|
||||||
|
assert := assert.New(t)
|
||||||
|
plugin := Plugin{}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
|
||||||
|
plugin.ServeHTTP(nil, w, r)
|
||||||
|
|
||||||
|
result := w.Result()
|
||||||
|
assert.NotNil(result)
|
||||||
|
defer result.Body.Close()
|
||||||
|
bodyBytes, err := ioutil.ReadAll(result.Body)
|
||||||
|
assert.Nil(err)
|
||||||
|
bodyString := string(bodyBytes)
|
||||||
|
|
||||||
|
assert.Equal("Hello, world!", bodyString)
|
||||||
|
}
|
||||||
546
server/store.go
Normal file
546
server/store.go
Normal file
@ -0,0 +1,546 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
"github.com/mattermost/mattermost-server/v5/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errInvalidBadge = errors.New("invalid badge")
|
||||||
|
var errBadgeNotFound = errors.New("badge not found")
|
||||||
|
|
||||||
|
type Store interface {
|
||||||
|
// Interface
|
||||||
|
GetUserBadges(userID string) ([]*badgesmodel.UserBadge, error)
|
||||||
|
GetAllBadges() ([]*badgesmodel.AllBadgesBadge, error)
|
||||||
|
GetBadgeDetails(badgeID badgesmodel.BadgeID) (*badgesmodel.BadgeDetails, error)
|
||||||
|
|
||||||
|
// Autocomplete
|
||||||
|
GetRawBadges() ([]*badgesmodel.Badge, error)
|
||||||
|
GetRawTypes() (badgesmodel.BadgeTypeList, error)
|
||||||
|
|
||||||
|
// API
|
||||||
|
AddBadge(badge *badgesmodel.Badge) (*badgesmodel.Badge, error)
|
||||||
|
GrantBadge(badgeID badgesmodel.BadgeID, userID string, grantedBy string, reason string) (bool, error)
|
||||||
|
AddType(t *badgesmodel.BadgeTypeDefinition) (*badgesmodel.BadgeTypeDefinition, error)
|
||||||
|
GetType(tID badgesmodel.BadgeType) (*badgesmodel.BadgeTypeDefinition, error)
|
||||||
|
GetBadge(badgeID badgesmodel.BadgeID) (*badgesmodel.Badge, error)
|
||||||
|
UpdateType(t *badgesmodel.BadgeTypeDefinition) error
|
||||||
|
UpdateBadge(b *badgesmodel.Badge) error
|
||||||
|
DeleteType(tID badgesmodel.BadgeType) error
|
||||||
|
DeleteBadge(bID badgesmodel.BadgeID) error
|
||||||
|
|
||||||
|
AddSubscription(tID badgesmodel.BadgeType, cID string) error
|
||||||
|
RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error
|
||||||
|
GetTypeSubscriptions(tID badgesmodel.BadgeType) ([]string, error)
|
||||||
|
GetChannelSubscriptions(cID string) ([]*badgesmodel.BadgeTypeDefinition, error)
|
||||||
|
|
||||||
|
// PAPI
|
||||||
|
EnsureBadges(badges []*badgesmodel.Badge, pluginID, botID string) ([]*badgesmodel.Badge, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type store struct {
|
||||||
|
api plugin.API
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(api plugin.API) Store {
|
||||||
|
return &store{
|
||||||
|
api: api,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) EnsureBadges(badges []*badgesmodel.Badge, pluginID, botID string) ([]*badgesmodel.Badge, error) {
|
||||||
|
l, _, err := s.getAllTypes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tDef *badgesmodel.BadgeTypeDefinition
|
||||||
|
for i, t := range l {
|
||||||
|
if t.CreatedBy == botID {
|
||||||
|
tDef = l[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tDef == nil {
|
||||||
|
tDef, err = s.addType(&badgesmodel.BadgeTypeDefinition{
|
||||||
|
Name: "Plugin badges: " + pluginID,
|
||||||
|
CreatedBy: botID,
|
||||||
|
}, true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bb, _, err := s.getAllBadges()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []*badgesmodel.Badge{}
|
||||||
|
for _, pb := range badges {
|
||||||
|
found := false
|
||||||
|
for _, b := range bb {
|
||||||
|
if b.CreatedBy == botID && b.Name == pb.Name {
|
||||||
|
found = true
|
||||||
|
out = append(out, b)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
pb.Type = tDef.ID
|
||||||
|
pb.CreatedBy = botID
|
||||||
|
newBadge, err := s.AddBadge(pb)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, newBadge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) AddBadge(b *badgesmodel.Badge) (*badgesmodel.Badge, error) {
|
||||||
|
if !b.IsValid() {
|
||||||
|
return nil, errInvalidBadge
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeTypes, _, err := s.getAllTypes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
t := badgeTypes.GetType(b.Type)
|
||||||
|
if t == nil {
|
||||||
|
return nil, errors.New("missing badge type")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ID = badgesmodel.BadgeID(model.NewId())
|
||||||
|
err = s.doAtomic(func() (bool, error) { return s.atomicAddBadge(b) })
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) AddType(t *badgesmodel.BadgeTypeDefinition) (*badgesmodel.BadgeTypeDefinition, error) {
|
||||||
|
return s.addType(t, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) addType(t *badgesmodel.BadgeTypeDefinition, isPlugin bool) (*badgesmodel.BadgeTypeDefinition, error) {
|
||||||
|
t.ID = badgesmodel.BadgeType(model.NewId())
|
||||||
|
err := s.doAtomic(func() (bool, error) { return s.atomicAddType(t) })
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetAllBadges() ([]*badgesmodel.AllBadgesBadge, error) {
|
||||||
|
badges, _, err := s.getAllBadges()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ownership, _, err := s.getOwnershipList()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []*badgesmodel.AllBadgesBadge{}
|
||||||
|
for _, b := range badges {
|
||||||
|
badge := &badgesmodel.AllBadgesBadge{
|
||||||
|
Badge: *b,
|
||||||
|
}
|
||||||
|
grantedTo := map[string]bool{}
|
||||||
|
for _, o := range ownership {
|
||||||
|
if o.Badge != badge.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
badge.GrantedTimes++
|
||||||
|
|
||||||
|
if !grantedTo[o.User] {
|
||||||
|
badge.Granted++
|
||||||
|
grantedTo[o.User] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
badge.TypeName = "unknown"
|
||||||
|
t, err := s.GetType(badge.Type)
|
||||||
|
if err == nil {
|
||||||
|
badge.TypeName = t.Name
|
||||||
|
}
|
||||||
|
out = append(out, badge)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetRawBadges() ([]*badgesmodel.Badge, error) {
|
||||||
|
bb, _, err := s.getAllBadges()
|
||||||
|
return bb, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetRawTypes() (badgesmodel.BadgeTypeList, error) {
|
||||||
|
tt, _, err := s.getAllTypes()
|
||||||
|
return tt, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getAllTypes() (badgesmodel.BadgeTypeList, []byte, error) {
|
||||||
|
data, appErr := s.api.KVGet(KVKeyTypes)
|
||||||
|
if appErr != nil {
|
||||||
|
return nil, nil, appErr
|
||||||
|
}
|
||||||
|
|
||||||
|
typeList := []*badgesmodel.BadgeTypeDefinition{}
|
||||||
|
if data != nil {
|
||||||
|
err := json.Unmarshal(data, &typeList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeList, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getAllBadges() ([]*badgesmodel.Badge, []byte, error) {
|
||||||
|
data, appErr := s.api.KVGet(KVKeyBadges)
|
||||||
|
if appErr != nil {
|
||||||
|
return nil, nil, appErr
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeList := []*badgesmodel.Badge{}
|
||||||
|
if data != nil {
|
||||||
|
err := json.Unmarshal(data, &badgeList)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return badgeList, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getBadge(id badgesmodel.BadgeID) (*badgesmodel.Badge, error) {
|
||||||
|
badgeList, _, err := s.getAllBadges()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.getBadgeFromList(id, badgeList)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetBadgeDetails(id badgesmodel.BadgeID) (*badgesmodel.BadgeDetails, error) {
|
||||||
|
badge, err := s.getBadge(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owners, err := s.getBadgeUsers(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
createdByName := "unknown"
|
||||||
|
u, appErr := s.api.GetUser(badge.CreatedBy)
|
||||||
|
if appErr == nil {
|
||||||
|
conf := s.api.GetConfig()
|
||||||
|
if conf != nil {
|
||||||
|
format := conf.TeamSettings.TeammateNameDisplay
|
||||||
|
if format != nil {
|
||||||
|
createdByName = u.GetDisplayName(*format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typeName := "unknown"
|
||||||
|
t, err := s.GetType(badge.Type)
|
||||||
|
if err == nil {
|
||||||
|
typeName = t.Name
|
||||||
|
}
|
||||||
|
return &badgesmodel.BadgeDetails{
|
||||||
|
Badge: *badge,
|
||||||
|
Owners: owners,
|
||||||
|
CreatedByUsername: createdByName,
|
||||||
|
TypeName: typeName,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getOwnershipList() (badgesmodel.OwnershipList, []byte, error) {
|
||||||
|
data, appErr := s.api.KVGet(KVKeyOwnership)
|
||||||
|
if appErr != nil {
|
||||||
|
return nil, nil, appErr
|
||||||
|
}
|
||||||
|
|
||||||
|
ownership := badgesmodel.OwnershipList{}
|
||||||
|
if data != nil {
|
||||||
|
err := json.Unmarshal(data, &ownership)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ownership, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GrantBadge(id badgesmodel.BadgeID, userID string, grantedBy string, reason string) (bool, error) {
|
||||||
|
badge, err := s.getBadge(id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
types, _, err := s.getAllTypes()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
badgeType := types.GetType(badge.Type)
|
||||||
|
if badgeType == nil {
|
||||||
|
return false, errors.New("badge type not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
ownership := badgesmodel.Ownership{
|
||||||
|
User: userID,
|
||||||
|
Badge: badge.ID,
|
||||||
|
Time: time.Now(),
|
||||||
|
Reason: reason,
|
||||||
|
GrantedBy: grantedBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
shouldNotify := false
|
||||||
|
err = s.doAtomic(func() (bool, error) {
|
||||||
|
var done bool
|
||||||
|
var err error
|
||||||
|
shouldNotify, done, err = s.atomicAddBadgeToOwnership(ownership, badge.Multiple)
|
||||||
|
return done, err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return shouldNotify, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetUserBadges(userID string) ([]*badgesmodel.UserBadge, error) {
|
||||||
|
ownership, _, err := s.getOwnershipList()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
badges, _, err := s.getAllBadges()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []*badgesmodel.UserBadge{}
|
||||||
|
for _, o := range ownership {
|
||||||
|
if o.User == userID {
|
||||||
|
badge, err := s.getBadgeFromList(o.Badge, badges)
|
||||||
|
if err != nil {
|
||||||
|
s.api.LogDebug("Badge not found while getting user badges", "badgeID", o.Badge, "userID", userID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
grantedByName := "unknown"
|
||||||
|
u, appErr := s.api.GetUser(o.GrantedBy)
|
||||||
|
if appErr == nil {
|
||||||
|
conf := s.api.GetConfig()
|
||||||
|
if conf != nil {
|
||||||
|
format := conf.TeamSettings.TeammateNameDisplay
|
||||||
|
if format != nil {
|
||||||
|
grantedByName = u.GetDisplayName(*format)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
typeName := "unknown"
|
||||||
|
t, err := s.GetType(badge.Type)
|
||||||
|
if err == nil {
|
||||||
|
typeName = t.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
out = append([]*badgesmodel.UserBadge{{Badge: *badge, Ownership: o, GrantedByUsername: grantedByName, TypeName: typeName}}, out...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetType(tID badgesmodel.BadgeType) (*badgesmodel.BadgeTypeDefinition, error) {
|
||||||
|
tt, _, err := s.getAllTypes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, t := range tt {
|
||||||
|
if t.ID == tID {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New("not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetBadge(badgeID badgesmodel.BadgeID) (*badgesmodel.Badge, error) {
|
||||||
|
return s.getBadge(badgeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) UpdateType(t *badgesmodel.BadgeTypeDefinition) error {
|
||||||
|
return s.doAtomic(func() (bool, error) { return s.atomicUpdateType(t) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) UpdateBadge(b *badgesmodel.Badge) error {
|
||||||
|
return s.doAtomic(func() (bool, error) { return s.atomicUpdateBadge(b) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) atomicDeleteType(tID badgesmodel.BadgeType) (bool, error) {
|
||||||
|
tt, data, err := s.getAllTypes()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, t := range tt {
|
||||||
|
if t.ID == tID {
|
||||||
|
tt = append(tt[:i], tt[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.compareAndSet(KVKeyTypes, data, tt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) DeleteType(tID badgesmodel.BadgeType) error {
|
||||||
|
s.doAtomic(func() (bool, error) { return s.atomicDeleteType(tID) })
|
||||||
|
|
||||||
|
bb, _, err := s.getAllBadges()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, b := range bb {
|
||||||
|
if b.Type == tID {
|
||||||
|
s.api.LogDebug("Deleting badge", "name", b.Name)
|
||||||
|
err := s.DeleteBadge(b.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) DeleteBadge(bID badgesmodel.BadgeID) error {
|
||||||
|
err := s.doAtomic(func() (bool, error) { return s.atomicRemoveBadge(bID) })
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.doAtomic(func() (bool, error) { return s.atomicRemoveBadgeFromOwnership(bID) })
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getAllSubscriptions() ([]badgesmodel.Subscription, []byte, error) {
|
||||||
|
data, appErr := s.api.KVGet(KVKeySubscriptions)
|
||||||
|
if appErr != nil {
|
||||||
|
return nil, nil, appErr
|
||||||
|
}
|
||||||
|
|
||||||
|
subs := []badgesmodel.Subscription{}
|
||||||
|
if data != nil {
|
||||||
|
err := json.Unmarshal(data, &subs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return subs, data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) AddSubscription(tID badgesmodel.BadgeType, cID string) error {
|
||||||
|
toAdd := badgesmodel.Subscription{ChannelID: cID, TypeID: tID}
|
||||||
|
return s.doAtomic(func() (bool, error) { return s.atomicAddSubscription(toAdd) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) RemoveSubscriptions(tID badgesmodel.BadgeType, cID string) error {
|
||||||
|
toRemove := badgesmodel.Subscription{ChannelID: cID, TypeID: tID}
|
||||||
|
return s.doAtomic(func() (bool, error) { return s.atomicRemoveSubscription(toRemove) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetTypeSubscriptions(tID badgesmodel.BadgeType) ([]string, error) {
|
||||||
|
subs, _, err := s.getAllSubscriptions()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []string{}
|
||||||
|
for _, sub := range subs {
|
||||||
|
if sub.TypeID == tID {
|
||||||
|
out = append(out, sub.ChannelID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) GetChannelSubscriptions(cID string) ([]*badgesmodel.BadgeTypeDefinition, error) {
|
||||||
|
subs, _, err := s.getAllSubscriptions()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []*badgesmodel.BadgeTypeDefinition{}
|
||||||
|
for _, sub := range subs {
|
||||||
|
if sub.ChannelID == cID {
|
||||||
|
t, err := s.GetType(sub.TypeID)
|
||||||
|
if err != nil {
|
||||||
|
s.api.LogDebug("cannot get type", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getBadgeFromList(badgeID badgesmodel.BadgeID, list []*badgesmodel.Badge) (*badgesmodel.Badge, error) {
|
||||||
|
for _, badge := range list {
|
||||||
|
if badgeID == badge.ID {
|
||||||
|
return badge, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errBadgeNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) getBadgeUsers(badgeID badgesmodel.BadgeID) (badgesmodel.OwnershipList, error) {
|
||||||
|
_, err := s.getBadge(badgeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errBadgeNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
ownership, _, err := s.getOwnershipList()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := badgesmodel.OwnershipList{}
|
||||||
|
for _, o := range ownership {
|
||||||
|
if o.Badge == badgeID {
|
||||||
|
out = append(out, o)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
199
server/store_atomic.go
Normal file
199
server/store_atomic.go
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
|
||||||
|
)
|
||||||
|
|
||||||
|
const ATOMICRETRIES = 3
|
||||||
|
|
||||||
|
func (s *store) doAtomic(f func() (bool, error)) error {
|
||||||
|
done := false
|
||||||
|
for i := 0; i < ATOMICRETRIES; i++ {
|
||||||
|
var err error
|
||||||
|
done, err = f()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if done {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !done {
|
||||||
|
return errors.New("too many attempts on atomic retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) compareAndSet(key string, old []byte, value interface{}) (bool, error) {
|
||||||
|
data, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
done, appErr := s.api.KVCompareAndSet(key, old, data)
|
||||||
|
if appErr != nil {
|
||||||
|
return false, appErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return done, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) atomicRemoveBadge(bID badgesmodel.BadgeID) (bool, error) {
|
||||||
|
bb, data, err := s.getAllBadges()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, b := range bb {
|
||||||
|
if b.ID == bID {
|
||||||
|
bb = append(bb[:i], bb[i+1:]...)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.compareAndSet(KVKeyBadges, data, bb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) atomicRemoveBadgeFromOwnership(bID badgesmodel.BadgeID) (bool, error) {
|
||||||
|
ownership, data, err := s.getOwnershipList()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
toDelete := []int{}
|
||||||
|
for i, o := range ownership {
|
||||||
|
if o.Badge == bID {
|
||||||
|
toDelete = append([]int{i}, toDelete...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, index := range toDelete {
|
||||||
|
ownership = append(ownership[:index], ownership[index+1:]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.compareAndSet(KVKeyOwnership, data, ownership)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) atomicAddBadge(b *badgesmodel.Badge) (bool, error) {
|
||||||
|
bb, data, err := s.getAllBadges()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
bb = append(bb, b)
|
||||||
|
|
||||||
|
return s.compareAndSet(KVKeyBadges, data, bb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) atomicAddType(t *badgesmodel.BadgeTypeDefinition) (bool, error) {
|
||||||
|
tt, data, err := s.getAllTypes()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tt = append(tt, t)
|
||||||
|
|
||||||
|
return s.compareAndSet(KVKeyTypes, data, tt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) atomicAddBadgeToOwnership(o badgesmodel.Ownership, isMultiple bool) (shouldNotify bool, done bool, err error) {
|
||||||
|
ownership, data, err := s.getOwnershipList()
|
||||||
|
if err != nil {
|
||||||
|
return false, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isMultiple && ownership.IsOwned(o.User, o.Badge) {
|
||||||
|
return false, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ownership = append(ownership, o)
|
||||||
|
|
||||||
|
done, err = s.compareAndSet(KVKeyOwnership, data, ownership)
|
||||||
|
return done, done, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) atomicUpdateType(t *badgesmodel.BadgeTypeDefinition) (bool, error) {
|
||||||
|
tt, data, err := s.getAllTypes()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, tOld := range tt {
|
||||||
|
if tOld.ID == t.ID {
|
||||||
|
tt[i] = t
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return false, errors.New("not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.compareAndSet(KVKeyTypes, data, tt)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) atomicUpdateBadge(b *badgesmodel.Badge) (bool, error) {
|
||||||
|
bb, data, err := s.getAllBadges()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, bOld := range bb {
|
||||||
|
if bOld.ID == b.ID {
|
||||||
|
bb[i] = b
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false, errors.New("not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.compareAndSet(KVKeyBadges, data, bb)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) atomicAddSubscription(toAdd badgesmodel.Subscription) (bool, error) {
|
||||||
|
subs, data, err := s.getAllSubscriptions()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sub := range subs {
|
||||||
|
if sub.ChannelID == toAdd.ChannelID && sub.TypeID == toAdd.TypeID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subs = append(subs, toAdd)
|
||||||
|
|
||||||
|
return s.compareAndSet(KVKeySubscriptions, data, subs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *store) atomicRemoveSubscription(toRemove badgesmodel.Subscription) (bool, error) {
|
||||||
|
subs, data, err := s.getAllSubscriptions()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i, sub := range subs {
|
||||||
|
if sub.ChannelID == toRemove.ChannelID && sub.TypeID == toRemove.TypeID {
|
||||||
|
subs = append(subs[:i], subs[i+1:]...)
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.compareAndSet(KVKeySubscriptions, data, subs)
|
||||||
|
}
|
||||||
80
server/suggestions.go
Normal file
80
server/suggestions.go
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Plugin) filterGrantBadges(user *model.User) ([]*badgesmodel.Badge, error) {
|
||||||
|
badges, err := p.store.GetRawBadges()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
types, err := p.store.GetRawTypes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []*badgesmodel.Badge{}
|
||||||
|
for _, b := range badges {
|
||||||
|
badgeType := types.GetType(b.Type)
|
||||||
|
if badgeType == nil {
|
||||||
|
p.mm.Log.Debug("Badge with missing type", "badge", b)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if canGrantBadge(user, p.badgeAdminUserID, b, badgeType) {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) filterCreateBadgeTypes(user *model.User) (badgesmodel.BadgeTypeList, error) {
|
||||||
|
types, err := p.store.GetRawTypes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := badgesmodel.BadgeTypeList{}
|
||||||
|
for _, t := range types {
|
||||||
|
if canCreateBadge(user, p.badgeAdminUserID, t) {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) filterEditTypes(user *model.User) (badgesmodel.BadgeTypeList, error) {
|
||||||
|
types, err := p.store.GetRawTypes()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := badgesmodel.BadgeTypeList{}
|
||||||
|
for _, t := range types {
|
||||||
|
if canEditType(user, p.badgeAdminUserID, t) {
|
||||||
|
out = append(out, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) filterEditBadges(user *model.User) ([]*badgesmodel.Badge, error) {
|
||||||
|
bb, err := p.store.GetRawBadges()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := []*badgesmodel.Badge{}
|
||||||
|
for _, b := range bb {
|
||||||
|
if canEditBadge(user, p.badgeAdminUserID, b) {
|
||||||
|
out = append(out, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
211
server/utils.go
Normal file
211
server/utils.go
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/larkox/mattermost-plugin-badges/badgesmodel"
|
||||||
|
"github.com/mattermost/mattermost-server/v5/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func areRolesAllowed(userRoles []string, allowedRoles map[string]bool) bool {
|
||||||
|
for ar, b := range allowedRoles {
|
||||||
|
if !b {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ur := range userRoles {
|
||||||
|
if ar == ur {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func canGrantBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||||
|
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.IsSystemAdmin() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if badgeType.CreatedBy == user.Id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if badge.CreatedBy == user.Id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked := badgeType.CanGrant.BlockList[user.Id]
|
||||||
|
if blocked {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if areRolesAllowed(user.GetRoles(), badgeType.CanGrant.Roles) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := badgeType.CanGrant.AllowList[user.Id]
|
||||||
|
if allowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return badgeType.CanGrant.Everyone
|
||||||
|
}
|
||||||
|
|
||||||
|
func canCreateBadge(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||||
|
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.IsSystemAdmin() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if badgeType.CreatedBy == user.Id {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
blocked := badgeType.CanCreate.BlockList[user.Id]
|
||||||
|
if blocked {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if areRolesAllowed(user.GetRoles(), badgeType.CanCreate.Roles) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed := badgeType.CanCreate.AllowList[user.Id]
|
||||||
|
if allowed {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return badgeType.CanCreate.Everyone
|
||||||
|
}
|
||||||
|
|
||||||
|
func canEditType(user *model.User, badgeAdminID string, badgeType *badgesmodel.BadgeTypeDefinition) bool {
|
||||||
|
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.IsSystemAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
func canEditBadge(user *model.User, badgeAdminID string, badge *badgesmodel.Badge) bool {
|
||||||
|
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.IsSystemAdmin() || user.Id == badge.CreatedBy
|
||||||
|
}
|
||||||
|
|
||||||
|
func canCreateType(user *model.User, badgeAdminID string, isPlugin bool) bool {
|
||||||
|
if isPlugin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.IsSystemAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
func canCreateSubscription(user *model.User, badgeAdminID string, channelID string) bool {
|
||||||
|
if badgeAdminID != "" && user.Id == badgeAdminID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.IsSystemAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
func dumpObject(o interface{}) {
|
||||||
|
b, err := json.MarshalIndent(o, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err.Error())
|
||||||
|
}
|
||||||
|
fmt.Println(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Plugin) notifyGrant(badgeID badgesmodel.BadgeID, granter string, granted *model.User, inChannel bool, channelID string, reason string) {
|
||||||
|
b, errBadge := p.store.GetBadgeDetails(badgeID)
|
||||||
|
granterUser, errUser := p.mm.User.Get(granter)
|
||||||
|
if errBadge != nil {
|
||||||
|
p.mm.Log.Debug("badge error", "err", errBadge)
|
||||||
|
}
|
||||||
|
if errUser != nil {
|
||||||
|
p.mm.Log.Debug("user error", "err", errUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
subs, _ := p.store.GetTypeSubscriptions(b.Type)
|
||||||
|
|
||||||
|
if errBadge == nil && errUser == nil {
|
||||||
|
image := ""
|
||||||
|
switch b.ImageType {
|
||||||
|
case badgesmodel.ImageTypeEmoji:
|
||||||
|
image = fmt.Sprintf(":%s: ", b.Image)
|
||||||
|
case badgesmodel.ImageTypeAbsoluteURL:
|
||||||
|
image = fmt.Sprintf(" ", b.Image)
|
||||||
|
}
|
||||||
|
|
||||||
|
dmPost := &model.Post{}
|
||||||
|
dmText := fmt.Sprintf("@%s granted you the %s`%s` badge.", granterUser.Username, image, b.Name)
|
||||||
|
if reason != "" {
|
||||||
|
dmText += "\nWhy? " + reason
|
||||||
|
}
|
||||||
|
dmAttachment := model.SlackAttachment{
|
||||||
|
Title: fmt.Sprintf("%sbadge granted!", image),
|
||||||
|
Text: dmText,
|
||||||
|
}
|
||||||
|
model.ParseSlackAttachment(dmPost, []*model.SlackAttachment{&dmAttachment})
|
||||||
|
err := p.mm.Post.DM(p.BotUserID, granted.Id, dmPost)
|
||||||
|
if err != nil {
|
||||||
|
p.mm.Log.Debug("dm error", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
basePost := model.Post{
|
||||||
|
UserId: p.BotUserID,
|
||||||
|
ChannelId: channelID,
|
||||||
|
}
|
||||||
|
text := fmt.Sprintf("@%s granted @%s the %s`%s` badge.", granterUser.Username, granted.Username, image, b.Name)
|
||||||
|
if reason != "" {
|
||||||
|
text += "\nWhy? " + reason
|
||||||
|
}
|
||||||
|
attachment := model.SlackAttachment{
|
||||||
|
Title: fmt.Sprintf("%sbadge granted!", image),
|
||||||
|
Text: text,
|
||||||
|
}
|
||||||
|
model.ParseSlackAttachment(&basePost, []*model.SlackAttachment{&attachment})
|
||||||
|
for _, sub := range subs {
|
||||||
|
post := basePost.Clone()
|
||||||
|
post.ChannelId = sub
|
||||||
|
err := p.mm.Post.CreatePost(post)
|
||||||
|
if err != nil {
|
||||||
|
p.mm.Log.Debug("notify subscription error", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inChannel {
|
||||||
|
if !p.API.HasPermissionToChannel(granter, channelID, model.PERMISSION_CREATE_POST) {
|
||||||
|
p.mm.Post.SendEphemeralPost(granter, &model.Post{Message: "You don't have permissions to notify the grant on this channel.", ChannelId: channelID})
|
||||||
|
} else {
|
||||||
|
post := basePost.Clone()
|
||||||
|
post.ChannelId = channelID
|
||||||
|
err := p.mm.Post.CreatePost(post)
|
||||||
|
if err != nil {
|
||||||
|
p.mm.Log.Debug("notify here error", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getBooleanString(in bool) string {
|
||||||
|
if in {
|
||||||
|
return TrueString
|
||||||
|
}
|
||||||
|
return FalseString
|
||||||
|
}
|
||||||
704
webapp/.eslintrc.json
Normal file
704
webapp/.eslintrc.json
Normal file
@ -0,0 +1,704 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:react-hooks/recommended"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 8,
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaFeatures": {
|
||||||
|
"jsx": true,
|
||||||
|
"impliedStrict": true,
|
||||||
|
"modules": true,
|
||||||
|
"experimentalObjectRestSpread": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parser": "babel-eslint",
|
||||||
|
"plugins": [
|
||||||
|
"react",
|
||||||
|
"import"
|
||||||
|
],
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"node": true,
|
||||||
|
"jquery": true,
|
||||||
|
"es6": true,
|
||||||
|
"jest": true
|
||||||
|
},
|
||||||
|
"globals": {
|
||||||
|
"jest": true,
|
||||||
|
"describe": true,
|
||||||
|
"it": true,
|
||||||
|
"expect": true,
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"beforeEach": true
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"import/resolver": "webpack"
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"eol-last": ["error", "always"],
|
||||||
|
"array-bracket-spacing": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"array-callback-return": 2,
|
||||||
|
"arrow-body-style": 0,
|
||||||
|
"arrow-parens": [
|
||||||
|
2,
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"arrow-spacing": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"before": true,
|
||||||
|
"after": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"block-scoped-var": 2,
|
||||||
|
"brace-style": [
|
||||||
|
2,
|
||||||
|
"1tbs",
|
||||||
|
{
|
||||||
|
"allowSingleLine": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"capitalized-comments": 0,
|
||||||
|
"class-methods-use-this": 0,
|
||||||
|
"comma-dangle": [
|
||||||
|
2,
|
||||||
|
"always-multiline"
|
||||||
|
],
|
||||||
|
"comma-spacing": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"before": false,
|
||||||
|
"after": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"comma-style": [
|
||||||
|
2,
|
||||||
|
"last"
|
||||||
|
],
|
||||||
|
"complexity": [
|
||||||
|
0,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"computed-property-spacing": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"consistent-return": 2,
|
||||||
|
"consistent-this": [
|
||||||
|
2,
|
||||||
|
"self"
|
||||||
|
],
|
||||||
|
"constructor-super": 2,
|
||||||
|
"curly": [
|
||||||
|
2,
|
||||||
|
"all"
|
||||||
|
],
|
||||||
|
"dot-location": [
|
||||||
|
2,
|
||||||
|
"object"
|
||||||
|
],
|
||||||
|
"dot-notation": 2,
|
||||||
|
"eqeqeq": [
|
||||||
|
2,
|
||||||
|
"smart"
|
||||||
|
],
|
||||||
|
"func-call-spacing": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"func-name-matching": 0,
|
||||||
|
"func-names": 2,
|
||||||
|
"func-style": [
|
||||||
|
2,
|
||||||
|
"declaration",
|
||||||
|
{
|
||||||
|
"allowArrowFunctions": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"generator-star-spacing": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"before": false,
|
||||||
|
"after": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"global-require": 2,
|
||||||
|
"guard-for-in": 2,
|
||||||
|
"id-blacklist": 0,
|
||||||
|
"import/no-unresolved": 2,
|
||||||
|
"import/order": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"newlines-between": "always-and-inside-groups",
|
||||||
|
"groups": [
|
||||||
|
"builtin",
|
||||||
|
"external",
|
||||||
|
[
|
||||||
|
"internal",
|
||||||
|
"parent"
|
||||||
|
],
|
||||||
|
"sibling",
|
||||||
|
"index"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"indent": [
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
{
|
||||||
|
"SwitchCase": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"jsx-quotes": [
|
||||||
|
2,
|
||||||
|
"prefer-single"
|
||||||
|
],
|
||||||
|
"key-spacing": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"beforeColon": false,
|
||||||
|
"afterColon": true,
|
||||||
|
"mode": "strict"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"keyword-spacing": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"before": true,
|
||||||
|
"after": true,
|
||||||
|
"overrides": {}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"line-comment-position": 0,
|
||||||
|
"linebreak-style": 2,
|
||||||
|
"lines-around-comment": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"beforeBlockComment": true,
|
||||||
|
"beforeLineComment": true,
|
||||||
|
"allowBlockStart": true,
|
||||||
|
"allowBlockEnd": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max-lines": [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
"max": 450,
|
||||||
|
"skipBlankLines": true,
|
||||||
|
"skipComments": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max-nested-callbacks": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"max": 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"max-statements-per-line": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"max": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multiline-ternary": [
|
||||||
|
1,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"new-cap": 2,
|
||||||
|
"new-parens": 2,
|
||||||
|
"newline-before-return": 0,
|
||||||
|
"newline-per-chained-call": 0,
|
||||||
|
"no-alert": 2,
|
||||||
|
"no-array-constructor": 2,
|
||||||
|
"no-await-in-loop": 2,
|
||||||
|
"no-caller": 2,
|
||||||
|
"no-case-declarations": 2,
|
||||||
|
"no-class-assign": 2,
|
||||||
|
"no-compare-neg-zero": 2,
|
||||||
|
"no-cond-assign": [
|
||||||
|
2,
|
||||||
|
"except-parens"
|
||||||
|
],
|
||||||
|
"no-confusing-arrow": 2,
|
||||||
|
"no-console": 2,
|
||||||
|
"no-const-assign": 2,
|
||||||
|
"no-constant-condition": 2,
|
||||||
|
"no-debugger": 2,
|
||||||
|
"no-div-regex": 2,
|
||||||
|
"no-dupe-args": 2,
|
||||||
|
"no-dupe-class-members": 2,
|
||||||
|
"no-dupe-keys": 2,
|
||||||
|
"no-duplicate-case": 2,
|
||||||
|
"no-duplicate-imports": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"includeExports": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-else-return": 2,
|
||||||
|
"no-empty": 2,
|
||||||
|
"no-empty-function": 2,
|
||||||
|
"no-empty-pattern": 2,
|
||||||
|
"no-eval": 2,
|
||||||
|
"no-ex-assign": 2,
|
||||||
|
"no-extend-native": 2,
|
||||||
|
"no-extra-bind": 2,
|
||||||
|
"no-extra-label": 2,
|
||||||
|
"no-extra-parens": 0,
|
||||||
|
"no-extra-semi": 2,
|
||||||
|
"no-fallthrough": 2,
|
||||||
|
"no-floating-decimal": 2,
|
||||||
|
"no-func-assign": 2,
|
||||||
|
"no-global-assign": 2,
|
||||||
|
"no-implicit-coercion": 2,
|
||||||
|
"no-implicit-globals": 0,
|
||||||
|
"no-implied-eval": 2,
|
||||||
|
"no-inner-declarations": 0,
|
||||||
|
"no-invalid-regexp": 2,
|
||||||
|
"no-irregular-whitespace": 2,
|
||||||
|
"no-iterator": 2,
|
||||||
|
"no-labels": 2,
|
||||||
|
"no-lone-blocks": 2,
|
||||||
|
"no-lonely-if": 2,
|
||||||
|
"no-loop-func": 2,
|
||||||
|
"no-magic-numbers": [
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
"ignore": [
|
||||||
|
-1,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2
|
||||||
|
],
|
||||||
|
"enforceConst": true,
|
||||||
|
"detectObjects": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-mixed-operators": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"allowSamePrecedence": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-mixed-spaces-and-tabs": 2,
|
||||||
|
"no-multi-assign": 2,
|
||||||
|
"no-multi-spaces": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"exceptions": {
|
||||||
|
"Property": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-multi-str": 0,
|
||||||
|
"no-multiple-empty-lines": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"max": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-native-reassign": 2,
|
||||||
|
"no-negated-condition": 2,
|
||||||
|
"no-nested-ternary": 2,
|
||||||
|
"no-new": 2,
|
||||||
|
"no-new-func": 2,
|
||||||
|
"no-new-object": 2,
|
||||||
|
"no-new-symbol": 2,
|
||||||
|
"no-new-wrappers": 2,
|
||||||
|
"no-octal-escape": 2,
|
||||||
|
"no-param-reassign": 2,
|
||||||
|
"no-process-env": 2,
|
||||||
|
"no-process-exit": 2,
|
||||||
|
"no-proto": 2,
|
||||||
|
"no-redeclare": 2,
|
||||||
|
"no-return-assign": [
|
||||||
|
2,
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"no-return-await": 2,
|
||||||
|
"no-script-url": 2,
|
||||||
|
"no-self-assign": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"props": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-self-compare": 2,
|
||||||
|
"no-sequences": 2,
|
||||||
|
"no-shadow": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"hoist": "functions"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-shadow-restricted-names": 2,
|
||||||
|
"no-spaced-func": 2,
|
||||||
|
"no-tabs": 0,
|
||||||
|
"no-template-curly-in-string": 2,
|
||||||
|
"no-ternary": 0,
|
||||||
|
"no-this-before-super": 2,
|
||||||
|
"no-throw-literal": 2,
|
||||||
|
"no-trailing-spaces": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"skipBlankLines": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-undef-init": 2,
|
||||||
|
"no-undefined": 2,
|
||||||
|
"no-underscore-dangle": 2,
|
||||||
|
"no-unexpected-multiline": 2,
|
||||||
|
"no-unmodified-loop-condition": 2,
|
||||||
|
"no-unneeded-ternary": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"defaultAssignment": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-unreachable": 2,
|
||||||
|
"no-unsafe-finally": 2,
|
||||||
|
"no-unsafe-negation": 2,
|
||||||
|
"no-unused-expressions": 2,
|
||||||
|
"no-unused-vars": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"vars": "all",
|
||||||
|
"args": "after-used"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-use-before-define": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"classes": false,
|
||||||
|
"functions": false,
|
||||||
|
"variables": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-useless-computed-key": 2,
|
||||||
|
"no-useless-concat": 2,
|
||||||
|
"no-useless-constructor": 2,
|
||||||
|
"no-useless-escape": 2,
|
||||||
|
"no-useless-rename": 2,
|
||||||
|
"no-useless-return": 2,
|
||||||
|
"no-var": 0,
|
||||||
|
"no-void": 2,
|
||||||
|
"no-warning-comments": 1,
|
||||||
|
"no-whitespace-before-property": 2,
|
||||||
|
"no-with": 2,
|
||||||
|
"object-curly-newline": 0,
|
||||||
|
"object-curly-spacing": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"object-property-newline": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"allowMultiplePropertiesPerLine": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"object-shorthand": [
|
||||||
|
2,
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"one-var": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"one-var-declaration-per-line": 0,
|
||||||
|
"operator-assignment": [
|
||||||
|
2,
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"operator-linebreak": [
|
||||||
|
2,
|
||||||
|
"after"
|
||||||
|
],
|
||||||
|
"padded-blocks": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"prefer-arrow-callback": 2,
|
||||||
|
"prefer-const": 2,
|
||||||
|
"prefer-destructuring": 0,
|
||||||
|
"prefer-numeric-literals": 2,
|
||||||
|
"prefer-promise-reject-errors": 2,
|
||||||
|
"prefer-rest-params": 2,
|
||||||
|
"prefer-spread": 2,
|
||||||
|
"prefer-template": 0,
|
||||||
|
"quote-props": [
|
||||||
|
2,
|
||||||
|
"as-needed"
|
||||||
|
],
|
||||||
|
"quotes": [
|
||||||
|
2,
|
||||||
|
"single",
|
||||||
|
"avoid-escape"
|
||||||
|
],
|
||||||
|
"radix": 2,
|
||||||
|
"react/display-name": [
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
"ignoreTranspilerName": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/forbid-component-props": 0,
|
||||||
|
"react/forbid-elements": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"forbid": [
|
||||||
|
"embed"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/jsx-boolean-value": [
|
||||||
|
2,
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"react/jsx-closing-bracket-location": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"location": "tag-aligned"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/jsx-curly-spacing": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"react/jsx-equals-spacing": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"react/jsx-filename-extension": 2,
|
||||||
|
"react/jsx-first-prop-new-line": [
|
||||||
|
2,
|
||||||
|
"multiline"
|
||||||
|
],
|
||||||
|
"react/jsx-handler-names": 0,
|
||||||
|
"react/jsx-indent": [
|
||||||
|
2,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"react/jsx-indent-props": [
|
||||||
|
2,
|
||||||
|
4
|
||||||
|
],
|
||||||
|
"react/jsx-key": 2,
|
||||||
|
"react/jsx-max-props-per-line": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"maximum": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/jsx-no-bind": 0,
|
||||||
|
"react/jsx-no-comment-textnodes": 2,
|
||||||
|
"react/jsx-no-duplicate-props": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"ignoreCase": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/jsx-no-literals": 2,
|
||||||
|
"react/jsx-no-target-blank": 2,
|
||||||
|
"react/jsx-no-undef": 2,
|
||||||
|
"react/jsx-pascal-case": 2,
|
||||||
|
"react/jsx-tag-spacing": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"closingSlash": "never",
|
||||||
|
"beforeSelfClosing": "never",
|
||||||
|
"afterOpening": "never"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/jsx-uses-react": 2,
|
||||||
|
"react/jsx-uses-vars": 2,
|
||||||
|
"react/jsx-wrap-multilines": 2,
|
||||||
|
"react/no-array-index-key": 1,
|
||||||
|
"react/no-children-prop": 2,
|
||||||
|
"react/no-danger": 0,
|
||||||
|
"react/no-danger-with-children": 2,
|
||||||
|
"react/no-deprecated": 1,
|
||||||
|
"react/no-did-mount-set-state": 2,
|
||||||
|
"react/no-did-update-set-state": 2,
|
||||||
|
"react/no-direct-mutation-state": 2,
|
||||||
|
"react/no-find-dom-node": 1,
|
||||||
|
"react/no-is-mounted": 2,
|
||||||
|
"react/no-multi-comp": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"ignoreStateless": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/no-render-return-value": 2,
|
||||||
|
"react/no-set-state": 0,
|
||||||
|
"react/no-string-refs": 0,
|
||||||
|
"react/no-unescaped-entities": 2,
|
||||||
|
"react/no-unknown-property": 2,
|
||||||
|
"react/no-unused-prop-types": [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
"skipShapeProps": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/prefer-es6-class": 2,
|
||||||
|
"react/prefer-stateless-function": 2,
|
||||||
|
"react/prop-types": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"ignore": [
|
||||||
|
"location",
|
||||||
|
"history",
|
||||||
|
"component"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/require-default-props": 0,
|
||||||
|
"react/require-optimization": 1,
|
||||||
|
"react/require-render-return": 2,
|
||||||
|
"react/self-closing-comp": 2,
|
||||||
|
"react/sort-comp": 0,
|
||||||
|
"react/style-prop-object": 2,
|
||||||
|
"require-yield": 2,
|
||||||
|
"rest-spread-spacing": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"semi": [
|
||||||
|
2,
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"semi-spacing": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"before": false,
|
||||||
|
"after": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sort-imports": 0,
|
||||||
|
"sort-keys": 0,
|
||||||
|
"space-before-blocks": [
|
||||||
|
2,
|
||||||
|
"always"
|
||||||
|
],
|
||||||
|
"space-before-function-paren": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"anonymous": "never",
|
||||||
|
"named": "never",
|
||||||
|
"asyncArrow": "always"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"space-in-parens": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"space-infix-ops": 2,
|
||||||
|
"space-unary-ops": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"words": true,
|
||||||
|
"nonwords": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"symbol-description": 2,
|
||||||
|
"template-curly-spacing": [
|
||||||
|
2,
|
||||||
|
"never"
|
||||||
|
],
|
||||||
|
"valid-typeof": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"requireStringLiterals": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"vars-on-top": 0,
|
||||||
|
"wrap-iife": [
|
||||||
|
2,
|
||||||
|
"outside"
|
||||||
|
],
|
||||||
|
"wrap-regex": 2,
|
||||||
|
"yoda": [
|
||||||
|
2,
|
||||||
|
"never",
|
||||||
|
{
|
||||||
|
"exceptRange": false,
|
||||||
|
"onlyEquality": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.tsx", "**/*.ts"],
|
||||||
|
"extends": "plugin:@typescript-eslint/recommended",
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/member-delimiter-style": ["error"],
|
||||||
|
"camelcase": 0,
|
||||||
|
"no-shadow": 0,
|
||||||
|
"import/no-unresolved": 0, // ts handles this better
|
||||||
|
"@typescript-eslint/naming-convention": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"selector": "function",
|
||||||
|
"format": ["camelCase", "PascalCase"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "variable",
|
||||||
|
"format": ["camelCase", "PascalCase", "UPPER_CASE"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "parameter",
|
||||||
|
"format": ["camelCase", "PascalCase"],
|
||||||
|
"leadingUnderscore": "allow"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"selector": "typeLike",
|
||||||
|
"format": ["PascalCase"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-non-null-assertion": 0,
|
||||||
|
"@typescript-eslint/no-unused-vars": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"vars": "all",
|
||||||
|
"args": "after-used"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@typescript-eslint/no-empty-function": 0,
|
||||||
|
"@typescript-eslint/ban-ts-ignore": 0,
|
||||||
|
"@typescript-eslint/ban-types": 1,
|
||||||
|
"@typescript-eslint/ban-ts-comment": 0,
|
||||||
|
"@typescript-eslint/no-var-requires": 0,
|
||||||
|
"@typescript-eslint/prefer-interface": 0,
|
||||||
|
"@typescript-eslint/explicit-function-return-type": 0,
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": 0,
|
||||||
|
"@typescript-eslint/indent": [
|
||||||
|
2,
|
||||||
|
4,
|
||||||
|
{
|
||||||
|
"SwitchCase": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-use-before-define": "off",
|
||||||
|
"@typescript-eslint/no-use-before-define": [
|
||||||
|
2,
|
||||||
|
{
|
||||||
|
"classes": false,
|
||||||
|
"functions": false,
|
||||||
|
"variables": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/jsx-filename-extension": [
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
"extensions": [".jsx", ".tsx"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
3
webapp/.gitignore
vendored
Normal file
3
webapp/.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.eslintcache
|
||||||
|
junit.xml
|
||||||
|
node_modules
|
||||||
1
webapp/.npmrc
Normal file
1
webapp/.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
save-exact=true
|
||||||
BIN
webapp/.yarn/install-state.gz
Normal file
BIN
webapp/.yarn/install-state.gz
Normal file
Binary file not shown.
1
webapp/.yarnrc.yml
Normal file
1
webapp/.yarnrc.yml
Normal file
@ -0,0 +1 @@
|
|||||||
|
nodeLinker: node-modules
|
||||||
46
webapp/babel.config.js
Normal file
46
webapp/babel.config.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
presets: [
|
||||||
|
['@babel/preset-env', {
|
||||||
|
targets: {
|
||||||
|
chrome: 66,
|
||||||
|
firefox: 60,
|
||||||
|
edge: 42,
|
||||||
|
safari: 12,
|
||||||
|
},
|
||||||
|
modules: false,
|
||||||
|
corejs: 3,
|
||||||
|
debug: false,
|
||||||
|
useBuiltIns: 'usage',
|
||||||
|
shippedProposals: true,
|
||||||
|
}],
|
||||||
|
['@babel/preset-react', {
|
||||||
|
useBuiltIns: true,
|
||||||
|
}],
|
||||||
|
['@babel/typescript', {
|
||||||
|
allExtensions: true,
|
||||||
|
isTSX: true,
|
||||||
|
}],
|
||||||
|
['@emotion/babel-preset-css-prop'],
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
'@babel/plugin-proposal-class-properties',
|
||||||
|
'@babel/plugin-syntax-dynamic-import',
|
||||||
|
'@babel/proposal-object-rest-spread',
|
||||||
|
'@babel/plugin-proposal-optional-chaining',
|
||||||
|
'babel-plugin-typescript-to-proptypes',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jest needs module transformation
|
||||||
|
config.env = {
|
||||||
|
test: {
|
||||||
|
presets: config.presets,
|
||||||
|
plugins: config.plugins,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
config.env.test.presets[0][1].modules = 'auto';
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
1
webapp/i18n/en.json
Normal file
1
webapp/i18n/en.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
16862
webapp/package-lock.json
generated
Normal file
16862
webapp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
120
webapp/package.json
Normal file
120
webapp/package.json
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
{
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "webpack --mode=production",
|
||||||
|
"build:watch": "webpack --mode=production --watch",
|
||||||
|
"debug": "webpack --mode=none",
|
||||||
|
"debug:watch": "webpack --mode=development --watch",
|
||||||
|
"lint": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx --ext tsx --ext ts . --quiet --cache",
|
||||||
|
"fix": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx --ext tsx --ext ts . --quiet --fix --cache",
|
||||||
|
"test": "jest --forceExit --detectOpenHandles --verbose",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test-ci": "jest --forceExit --detectOpenHandles --maxWorkers=2",
|
||||||
|
"check-types": "tsc"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/cli": "7.13.16",
|
||||||
|
"@babel/core": "7.13.16",
|
||||||
|
"@babel/plugin-proposal-class-properties": "7.13.0",
|
||||||
|
"@babel/plugin-proposal-object-rest-spread": "7.13.8",
|
||||||
|
"@babel/plugin-proposal-optional-chaining": "7.13.12",
|
||||||
|
"@babel/plugin-syntax-dynamic-import": "7.8.3",
|
||||||
|
"@babel/polyfill": "7.12.1",
|
||||||
|
"@babel/preset-env": "7.13.15",
|
||||||
|
"@babel/preset-react": "7.13.13",
|
||||||
|
"@babel/preset-typescript": "7.13.0",
|
||||||
|
"@babel/runtime": "7.13.16",
|
||||||
|
"@emotion/babel-preset-css-prop": "11.2.0",
|
||||||
|
"@emotion/core": "11.0.0",
|
||||||
|
"@types/enzyme": "3.10.8",
|
||||||
|
"@types/jest": "26.0.22",
|
||||||
|
"@types/node": "14.14.41",
|
||||||
|
"@types/react": "17.0.3",
|
||||||
|
"@types/react-bootstrap": "0.32.25",
|
||||||
|
"@types/react-custom-scrollbars": "4.0.7",
|
||||||
|
"@types/react-dom": "17.0.3",
|
||||||
|
"@types/react-intl": "3.0.0",
|
||||||
|
"@types/react-redux": "7.1.16",
|
||||||
|
"@types/react-router-dom": "5.1.7",
|
||||||
|
"@types/react-transition-group": "4.4.1",
|
||||||
|
"@typescript-eslint/eslint-plugin": "4.22.0",
|
||||||
|
"@typescript-eslint/parser": "4.22.0",
|
||||||
|
"babel-eslint": "10.1.0",
|
||||||
|
"babel-jest": "26.6.3",
|
||||||
|
"babel-loader": "8.2.2",
|
||||||
|
"babel-plugin-typescript-to-proptypes": "1.4.2",
|
||||||
|
"css-loader": "5.2.4",
|
||||||
|
"enzyme": "3.11.0",
|
||||||
|
"enzyme-adapter-react-16": "1.15.6",
|
||||||
|
"enzyme-to-json": "3.6.2",
|
||||||
|
"eslint": "7.24.0",
|
||||||
|
"eslint-import-resolver-webpack": "0.13.0",
|
||||||
|
"eslint-plugin-import": "2.22.1",
|
||||||
|
"eslint-plugin-react": "7.23.2",
|
||||||
|
"eslint-plugin-react-hooks": "4.2.0",
|
||||||
|
"file-loader": "6.2.0",
|
||||||
|
"identity-obj-proxy": "3.0.0",
|
||||||
|
"jest": "26.6.3",
|
||||||
|
"jest-canvas-mock": "2.3.1",
|
||||||
|
"jest-junit": "12.0.0",
|
||||||
|
"loop-plugin-sdk": "https://artifacts.wilix.dev/repository/npm-public-loop/loop-plugin-sdk/-/loop-plugin-sdk-0.1.6.tgz",
|
||||||
|
"sass": "1.86.0",
|
||||||
|
"sass-loader": "11.0.1",
|
||||||
|
"style-loader": "2.0.0",
|
||||||
|
"webpack": "5.34.0",
|
||||||
|
"webpack-cli": "4.6.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "11.1.5",
|
||||||
|
"core-js": "3.10.2",
|
||||||
|
"mattermost-redux": "5.33.1",
|
||||||
|
"memoize-one": "^5.2.1",
|
||||||
|
"react": "17.0.2",
|
||||||
|
"react-custom-scrollbars": "^4.2.1",
|
||||||
|
"react-redux": "7.2.3",
|
||||||
|
"redux": "4.0.5",
|
||||||
|
"typescript": "4.2.4"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"snapshotSerializers": [
|
||||||
|
"<rootDir>/node_modules/enzyme-to-json/serializer"
|
||||||
|
],
|
||||||
|
"testPathIgnorePatterns": [
|
||||||
|
"/node_modules/",
|
||||||
|
"/non_npm_dependencies/"
|
||||||
|
],
|
||||||
|
"clearMocks": true,
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"src/**/*.{js,jsx}"
|
||||||
|
],
|
||||||
|
"coverageReporters": [
|
||||||
|
"lcov",
|
||||||
|
"text-summary"
|
||||||
|
],
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "identity-obj-proxy",
|
||||||
|
"^.+\\.(css|less|scss)$": "identity-obj-proxy",
|
||||||
|
"^.*i18n.*\\.(json)$": "<rootDir>/tests/i18n_mock.json",
|
||||||
|
"^bundle-loader\\?lazy\\!(.*)$": "$1"
|
||||||
|
},
|
||||||
|
"moduleDirectories": [
|
||||||
|
"",
|
||||||
|
"node_modules",
|
||||||
|
"non_npm_dependencies"
|
||||||
|
],
|
||||||
|
"reporters": [
|
||||||
|
"default",
|
||||||
|
"jest-junit"
|
||||||
|
],
|
||||||
|
"transformIgnorePatterns": [
|
||||||
|
"node_modules/(?!react-native|react-router|loop-plugin-sdk)"
|
||||||
|
],
|
||||||
|
"setupFiles": [
|
||||||
|
"jest-canvas-mock"
|
||||||
|
],
|
||||||
|
"setupFilesAfterEnv": [
|
||||||
|
"<rootDir>/tests/setup.tsx"
|
||||||
|
],
|
||||||
|
"testURL": "http://localhost:8065"
|
||||||
|
}
|
||||||
|
}
|
||||||
11
webapp/src/action_types/index.ts
Normal file
11
webapp/src/action_types/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {id as pluginId} from '../manifest';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
RECEIVED_SHOW_RHS_ACTION: pluginId + '_received_rhs_action',
|
||||||
|
RECEIVED_RHS_VIEW: pluginId + '_received_rhs_view',
|
||||||
|
RECEIVED_RHS_USER: pluginId + '_received_rhs_user',
|
||||||
|
RECEIVED_RHS_BADGE: pluginId + '_received_rhs_badge',
|
||||||
|
};
|
||||||
126
webapp/src/actions/actions.ts
Normal file
126
webapp/src/actions/actions.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import {AnyAction, Dispatch} from 'redux';
|
||||||
|
|
||||||
|
import {getCurrentChannel} from 'mattermost-redux/selectors/entities/channels';
|
||||||
|
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
|
||||||
|
import {GetStateFunc} from 'mattermost-redux/types/actions';
|
||||||
|
import {Client4} from 'mattermost-redux/client';
|
||||||
|
import {IntegrationTypes} from 'mattermost-redux/action_types';
|
||||||
|
|
||||||
|
import ActionTypes from 'action_types/';
|
||||||
|
import {BadgeID} from 'types/badges';
|
||||||
|
import {RHSState} from 'types/general';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores`showRHSPlugin` action returned by
|
||||||
|
* registerRightHandSidebarComponent in plugin initialization.
|
||||||
|
*/
|
||||||
|
export function setShowRHSAction(showRHSPluginAction: () => void) {
|
||||||
|
return {
|
||||||
|
type: ActionTypes.RECEIVED_SHOW_RHS_ACTION,
|
||||||
|
showRHSPluginAction,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRHSUser(userID: string | null) {
|
||||||
|
return {
|
||||||
|
type: ActionTypes.RECEIVED_RHS_USER,
|
||||||
|
data: userID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRHSBadge(badgeID: BadgeID | null) {
|
||||||
|
return {
|
||||||
|
type: ActionTypes.RECEIVED_RHS_BADGE,
|
||||||
|
data: badgeID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRHSView(view: RHSState) {
|
||||||
|
return {
|
||||||
|
type: ActionTypes.RECEIVED_RHS_VIEW,
|
||||||
|
data: view,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTriggerId(triggerId: string) {
|
||||||
|
return {
|
||||||
|
type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID,
|
||||||
|
data: triggerId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openGrant(user?: string, badge?: string) {
|
||||||
|
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||||
|
let command = '/badges grant';
|
||||||
|
if (user) {
|
||||||
|
command += ` --user ${user}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (badge) {
|
||||||
|
command += ` --badge ${badge}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
clientExecuteCommand(dispatch, getState, command);
|
||||||
|
|
||||||
|
return {data: true};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCreateType() {
|
||||||
|
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||||
|
const command = '/badges create type';
|
||||||
|
clientExecuteCommand(dispatch, getState, command);
|
||||||
|
|
||||||
|
return {data: true};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openCreateBadge() {
|
||||||
|
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||||
|
const command = '/badges create badge';
|
||||||
|
clientExecuteCommand(dispatch, getState, command);
|
||||||
|
|
||||||
|
return {data: true};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openAddSubscription() {
|
||||||
|
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||||
|
const command = '/badges subscription create';
|
||||||
|
clientExecuteCommand(dispatch, getState, command);
|
||||||
|
|
||||||
|
return {data: true};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openRemoveSubscription() {
|
||||||
|
return (dispatch: Dispatch<AnyAction>, getState: GetStateFunc) => {
|
||||||
|
const command = '/badges subscription remove';
|
||||||
|
clientExecuteCommand(dispatch, getState, command);
|
||||||
|
|
||||||
|
return {data: true};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clientExecuteCommand(dispatch: Dispatch<AnyAction>, getState: GetStateFunc, command: string) {
|
||||||
|
let currentChannel = getCurrentChannel(getState());
|
||||||
|
const currentTeamId = getCurrentTeamId(getState());
|
||||||
|
|
||||||
|
// Default to town square if there is no current channel (i.e., if Mattermost has not yet loaded)
|
||||||
|
if (!currentChannel) {
|
||||||
|
currentChannel = await Client4.getChannelByName(currentTeamId, 'town-square');
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = {
|
||||||
|
channel_id: currentChannel?.id,
|
||||||
|
team_id: currentTeamId,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
//@ts-ignore Typing in mattermost-redux is wrong
|
||||||
|
const data = await Client4.executeCommand(command, args);
|
||||||
|
dispatch(setTriggerId(data?.trigger_id));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error); //eslint-disable-line no-console
|
||||||
|
}
|
||||||
|
}
|
||||||
66
webapp/src/client/api.ts
Normal file
66
webapp/src/client/api.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {Client4} from 'mattermost-redux/client';
|
||||||
|
import {ClientError} from 'mattermost-redux/client/client4';
|
||||||
|
|
||||||
|
import manifest from 'manifest';
|
||||||
|
import {AllBadgesBadge, BadgeDetails, BadgeID, UserBadge} from 'types/badges';
|
||||||
|
|
||||||
|
export default class Client {
|
||||||
|
private url: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.url = '/plugins/' + manifest.id + '/api/v1';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserBadges(userID: string): Promise<UserBadge[]> {
|
||||||
|
try {
|
||||||
|
const res = await this.doGet(`${this.url}/getUserBadges/${userID}`);
|
||||||
|
return res as UserBadge[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBadgeDetails(badgeID: BadgeID): Promise<BadgeDetails|null> {
|
||||||
|
try {
|
||||||
|
const res = await this.doGet(`${this.url}/getBadgeDetails/${badgeID}`);
|
||||||
|
return res as BadgeDetails;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllBadges(): Promise<AllBadgesBadge[]> {
|
||||||
|
try {
|
||||||
|
const res = await this.doGet(`${this.url}/getAllBadges`);
|
||||||
|
return res as AllBadgesBadge[];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private doGet = async (url: string, headers: {[x:string]: string} = {}) => {
|
||||||
|
headers['X-Timezone-Offset'] = String(new Date().getTimezoneOffset());
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
method: 'get',
|
||||||
|
headers,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, Client4.getOptions(options));
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
throw new ClientError(Client4.url, {
|
||||||
|
message: text || '',
|
||||||
|
status_code: response.status,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
27
webapp/src/components/channel_header_button/index.tsx
Normal file
27
webapp/src/components/channel_header_button/index.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ChannelHeaderButton() {
|
||||||
|
const btnClass = 'icon fa fa-award';
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
aria-hidden='true'
|
||||||
|
focusable='false'
|
||||||
|
data-prefix='fas'
|
||||||
|
data-icon='award'
|
||||||
|
className={btnClass}
|
||||||
|
role='img'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
viewBox='0 0 384 512'
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill='currentColor'
|
||||||
|
d='M97.12 362.63c-8.69-8.69-4.16-6.24-25.12-11.85-9.51-2.55-17.87-7.45-25.43-13.32L1.2 448.7c-4.39 10.77 3.81 22.47 15.43 22.03l52.69-2.01L105.56 507c8 8.44 22.04 5.81 26.43-4.96l52.05-127.62c-10.84 6.04-22.87 9.58-35.31 9.58-19.5 0-37.82-7.59-51.61-21.37zM382.8 448.7l-45.37-111.24c-7.56 5.88-15.92 10.77-25.43 13.32-21.07 5.64-16.45 3.18-25.12 11.85-13.79 13.78-32.12 21.37-51.62 21.37-12.44 0-24.47-3.55-35.31-9.58L252 502.04c4.39 10.77 18.44 13.4 26.43 4.96l36.25-38.28 52.69 2.01c11.62.44 19.82-11.27 15.43-22.03zM263 340c15.28-15.55 17.03-14.21 38.79-20.14 13.89-3.79 24.75-14.84 28.47-28.98 7.48-28.4 5.54-24.97 25.95-45.75 10.17-10.35 14.14-25.44 10.42-39.58-7.47-28.38-7.48-24.42 0-52.83 3.72-14.14-.25-29.23-10.42-39.58-20.41-20.78-18.47-17.36-25.95-45.75-3.72-14.14-14.58-25.19-28.47-28.98-27.88-7.61-24.52-5.62-44.95-26.41-10.17-10.35-25-14.4-38.89-10.61-27.87 7.6-23.98 7.61-51.9 0-13.89-3.79-28.72.25-38.89 10.61-20.41 20.78-17.05 18.8-44.94 26.41-13.89 3.79-24.75 14.84-28.47 28.98-7.47 28.39-5.54 24.97-25.95 45.75-10.17 10.35-14.15 25.44-10.42 39.58 7.47 28.36 7.48 24.4 0 52.82-3.72 14.14.25 29.23 10.42 39.59 20.41 20.78 18.47 17.35 25.95 45.75 3.72 14.14 14.58 25.19 28.47 28.98C104.6 325.96 106.27 325 121 340c13.23 13.47 33.84 15.88 49.74 5.82a39.676 39.676 0 0 1 42.53 0c15.89 10.06 36.5 7.65 49.73-5.82zM97.66 175.96c0-53.03 42.24-96.02 94.34-96.02s94.34 42.99 94.34 96.02-42.24 96.02-94.34 96.02-94.34-42.99-94.34-96.02z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
webapp/src/components/rhs/all_badges.scss
Normal file
6
webapp/src/components/rhs/all_badges.scss
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.AllBadges {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
90
webapp/src/components/rhs/all_badges.tsx
Normal file
90
webapp/src/components/rhs/all_badges.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||||
|
|
||||||
|
import {BadgeID, AllBadgesBadge} from '../../types/badges';
|
||||||
|
import Client from '../../client/api';
|
||||||
|
|
||||||
|
import {RHSState} from '../../types/general';
|
||||||
|
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL} from '../../constants';
|
||||||
|
|
||||||
|
import AllBadgesRow from './all_badges_row';
|
||||||
|
import RHSScrollbars from './rhs_scrollbars';
|
||||||
|
|
||||||
|
import './all_badges.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
actions: {
|
||||||
|
setRHSView: (view: RHSState) => void;
|
||||||
|
setRHSBadge: (badge: BadgeID | null) => void;
|
||||||
|
getCustomEmojisByName: (names: string[]) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
loading: boolean;
|
||||||
|
badges?: AllBadgesBadge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class AllBadges extends React.PureComponent<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const c = new Client();
|
||||||
|
c.getAllBadges().then((badges) => {
|
||||||
|
this.setState({badges, loading: false});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||||
|
if (this.state.badges !== prevState.badges) {
|
||||||
|
const names: string[] = [];
|
||||||
|
this.state.badges?.forEach((badge) => {
|
||||||
|
if (badge.image_type === IMAGE_TYPE_EMOJI) {
|
||||||
|
names.push(badge.image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const toLoad = names.filter((v) => !systemEmojis.has(v));
|
||||||
|
this.props.actions.getCustomEmojisByName(toLoad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBadgeClick = (badge: AllBadgesBadge) => {
|
||||||
|
this.props.actions.setRHSBadge(badge.id);
|
||||||
|
this.props.actions.setRHSView(RHS_STATE_DETAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.loading) {
|
||||||
|
return (<div className='AllBadges'>{'Loading...'}</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.badges || this.state.badges.length === 0) {
|
||||||
|
return (<div className='AllBadges'>{'No badges yet.'}</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = this.state.badges.map((badge) => {
|
||||||
|
return (
|
||||||
|
<AllBadgesRow
|
||||||
|
key={badge.id}
|
||||||
|
badge={badge}
|
||||||
|
onClick={this.onBadgeClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className='AllBadges'>
|
||||||
|
<div><b>{'All badges'}</b></div>
|
||||||
|
<RHSScrollbars>{content}</RHSScrollbars>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AllBadges;
|
||||||
25
webapp/src/components/rhs/all_badges_row.scss
Normal file
25
webapp/src/components/rhs/all_badges_row.scss
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
.AllBadgesRow {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||||
|
border-radius: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
.badge-icon {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.badge-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.granted-by {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.badge-type {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.badge-descrition {
|
||||||
|
p {
|
||||||
|
margin: 0px
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
webapp/src/components/rhs/all_badges_row.tsx
Normal file
49
webapp/src/components/rhs/all_badges_row.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {AllBadgesBadge} from '../../types/badges';
|
||||||
|
import BadgeImage from '../utils/badge_image';
|
||||||
|
import {markdown} from 'utils/markdown';
|
||||||
|
|
||||||
|
import './all_badges_row.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
badge: AllBadgesBadge;
|
||||||
|
onClick: (badge: AllBadgesBadge) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGrantedText(badge: AllBadgesBadge): string {
|
||||||
|
if (badge.granted === 0) {
|
||||||
|
return 'Not yet granted.';
|
||||||
|
}
|
||||||
|
if (badge.multiple) {
|
||||||
|
return `Granted ${badge.granted_times} to ${badge.granted} users.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Granted to ${badge.granted} users.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AllBadgesRow: React.FC<Props> = ({badge, onClick}: Props) => {
|
||||||
|
return (
|
||||||
|
<div className='AllBadgesRow'>
|
||||||
|
<a
|
||||||
|
className='badge-icon'
|
||||||
|
onClick={() => onClick(badge)}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<BadgeImage
|
||||||
|
badge={badge}
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div>
|
||||||
|
<div className='badge-name'>{badge.name}</div>
|
||||||
|
<div className='badge-description'>{markdown(badge.description)}</div>
|
||||||
|
<div className='badge-type'>{'Type: ' + badge.type_name}</div>
|
||||||
|
<div className='granted-by'>{getGrantedText(badge)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AllBadgesRow;
|
||||||
28
webapp/src/components/rhs/badge_details.scss
Normal file
28
webapp/src/components/rhs/badge_details.scss
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
.BadgeDetails {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
.badge-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px;
|
||||||
|
.badge-icon {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.badge-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.created-by {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.badge-descrition {
|
||||||
|
p {
|
||||||
|
margin: 0px
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.badge-type {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
134
webapp/src/components/rhs/badge_details.tsx
Normal file
134
webapp/src/components/rhs/badge_details.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||||
|
|
||||||
|
import {BadgeDetails, BadgeID} from '../../types/badges';
|
||||||
|
import Client from '../../client/api';
|
||||||
|
|
||||||
|
import {RHSState} from '../../types/general';
|
||||||
|
import {RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
|
||||||
|
import BadgeImage from '../utils/badge_image';
|
||||||
|
|
||||||
|
import {markdown} from 'utils/markdown';
|
||||||
|
|
||||||
|
import RHSScrollbars from './rhs_scrollbars';
|
||||||
|
import UserRow from './user_row';
|
||||||
|
|
||||||
|
import './badge_details.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
badgeID: BadgeID | null;
|
||||||
|
currentUserID: string;
|
||||||
|
actions: {
|
||||||
|
setRHSView: (view: RHSState) => void;
|
||||||
|
setRHSUser: (user: string | null) => void;
|
||||||
|
getCustomEmojiByName: (names: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
loading: boolean;
|
||||||
|
badge?: BadgeDetails | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
class BadgeDetailsComponent extends React.PureComponent<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (this.props.badgeID === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = new Client();
|
||||||
|
c.getBadgeDetails(this.props.badgeID).then((badge) => {
|
||||||
|
this.setState({badge, loading: false});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||||
|
if (this.state.badge !== prevState.badge && this.state.badge && !systemEmojis.has(this.state.badge.name)) {
|
||||||
|
this.props.actions.getCustomEmojiByName(this.state.badge.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.badgeID === prevProps.badgeID) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.badgeID === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = new Client();
|
||||||
|
if (!this.state.loading) {
|
||||||
|
this.setState({loading: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
c.getBadgeDetails(this.props.badgeID).then((badge) => {
|
||||||
|
this.setState({badge, loading: false});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onUserClick = (user: string) => {
|
||||||
|
if (user === this.props.currentUserID) {
|
||||||
|
this.props.actions.setRHSView(RHS_STATE_MY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.actions.setRHSUser(user);
|
||||||
|
this.props.actions.setRHSView(RHS_STATE_OTHER);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {badge, loading} = this.state;
|
||||||
|
if (this.props.badgeID == null) {
|
||||||
|
return (<div>{'Badge not found.'}</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (<div>{'Loading...'}</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!badge) {
|
||||||
|
return (<div>{'Badge not found.'}</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = badge.owners.map((ownership) => {
|
||||||
|
return (
|
||||||
|
<UserRow
|
||||||
|
key={ownership.time}
|
||||||
|
ownership={ownership}
|
||||||
|
onClick={this.onUserClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div className='BadgeDetails'>
|
||||||
|
<div><b>{'Badge Details'}</b></div>
|
||||||
|
<div className='badge-info'>
|
||||||
|
<span className='badge-icon'>
|
||||||
|
<BadgeImage
|
||||||
|
badge={badge}
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<div className='badge-text'>
|
||||||
|
<div className='badge-name'>{badge.name}</div>
|
||||||
|
<div className='badge-description'>{markdown(badge.description)}</div>
|
||||||
|
<div className='badge-type'>{'Type: ' + badge.type_name}</div>
|
||||||
|
<div className='created-by'>{`Created by: ${badge.created_by_username}`}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div><b>{'Granted to:'}</b></div>
|
||||||
|
<RHSScrollbars>{content}</RHSScrollbars>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BadgeDetailsComponent;
|
||||||
85
webapp/src/components/rhs/index.tsx
Normal file
85
webapp/src/components/rhs/index.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See License for license information.
|
||||||
|
|
||||||
|
import {useDispatch, useSelector} from 'react-redux';
|
||||||
|
|
||||||
|
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {getUser} from 'mattermost-redux/selectors/entities/users';
|
||||||
|
|
||||||
|
import {GlobalState} from 'mattermost-redux/types/store';
|
||||||
|
|
||||||
|
import {getCustomEmojiByName, getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
|
||||||
|
|
||||||
|
import {getRHSBadge, getRHSUser, getRHSView} from 'selectors';
|
||||||
|
import {RHS_STATE_ALL, RHS_STATE_DETAIL, RHS_STATE_OTHER, RHS_STATE_MY} from '../../constants';
|
||||||
|
import {RHSState} from 'types/general';
|
||||||
|
import {setRHSBadge, setRHSUser, setRHSView} from 'actions/actions';
|
||||||
|
import {BadgeID} from 'types/badges';
|
||||||
|
|
||||||
|
import UserBadges from './user_badges';
|
||||||
|
import BadgeDetailsComponent from './badge_details';
|
||||||
|
import AllBadges from './all_badges';
|
||||||
|
|
||||||
|
const RHS: React.FC = () => {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const currentView = useSelector(getRHSView);
|
||||||
|
const currentBadge = useSelector(getRHSBadge);
|
||||||
|
const currentUserID = useSelector(getRHSUser);
|
||||||
|
const currentUser = useSelector((state: GlobalState) => getUser(state, (currentUserID as string)));
|
||||||
|
const myUser = useSelector(getCurrentUser);
|
||||||
|
|
||||||
|
switch (currentView) {
|
||||||
|
case RHS_STATE_ALL:
|
||||||
|
return (
|
||||||
|
<AllBadges
|
||||||
|
actions={{
|
||||||
|
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||||
|
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||||
|
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case RHS_STATE_DETAIL:
|
||||||
|
return (
|
||||||
|
<BadgeDetailsComponent
|
||||||
|
badgeID={currentBadge}
|
||||||
|
currentUserID={myUser.id}
|
||||||
|
actions={{
|
||||||
|
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||||
|
setRHSUser: (user: string | null) => dispatch(setRHSUser(user)),
|
||||||
|
getCustomEmojiByName: (names: string) => dispatch(getCustomEmojiByName(names)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case RHS_STATE_OTHER:
|
||||||
|
return (
|
||||||
|
<UserBadges
|
||||||
|
user={currentUser}
|
||||||
|
isCurrentUser={false}
|
||||||
|
actions={{
|
||||||
|
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||||
|
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||||
|
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case RHS_STATE_MY:
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<UserBadges
|
||||||
|
user={myUser}
|
||||||
|
isCurrentUser={true}
|
||||||
|
actions={{
|
||||||
|
setRHSView: (view: RHSState) => dispatch(setRHSView(view)),
|
||||||
|
setRHSBadge: (badge: BadgeID | null) => dispatch(setRHSBadge(badge)),
|
||||||
|
getCustomEmojisByName: (names: string[]) => dispatch(getCustomEmojisByName(names)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RHS;
|
||||||
45
webapp/src/components/rhs/rhs_scrollbars.tsx
Normal file
45
webapp/src/components/rhs/rhs_scrollbars.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Scrollbars from 'react-custom-scrollbars';
|
||||||
|
|
||||||
|
function renderView(props: any) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className='scrollbar--view'
|
||||||
|
/>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThumbHorizontal(props: any) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className='scrollbar--horizontal'
|
||||||
|
/>);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderThumbVertical(props: any) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className='scrollbar--vertical'
|
||||||
|
/>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RHSScrollbars = ({children}: {children: React.ReactNode[]}) => {
|
||||||
|
return (
|
||||||
|
<Scrollbars
|
||||||
|
autoHide={true}
|
||||||
|
autoHideTimeout={500}
|
||||||
|
autoHideDuration={500}
|
||||||
|
renderThumbHorizontal={renderThumbHorizontal}
|
||||||
|
renderThumbVertical={renderThumbVertical}
|
||||||
|
renderView={renderView}
|
||||||
|
style={{flex: '1 1 auto', height: ''}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Scrollbars>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RHSScrollbars;
|
||||||
28
webapp/src/components/rhs/user_badge_row.scss
Normal file
28
webapp/src/components/rhs/user_badge_row.scss
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
.UserBadgesRow {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||||
|
border-radius: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
.user-badge-icon {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.user-badge-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.user-badge-granted-by {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.user-badge-granted-at {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.user-badge-descrition {
|
||||||
|
p {
|
||||||
|
margin: 0px
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.user-badge-type {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
61
webapp/src/components/rhs/user_badge_row.tsx
Normal file
61
webapp/src/components/rhs/user_badge_row.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import Client4 from 'mattermost-redux/client/client4';
|
||||||
|
|
||||||
|
import {UserBadge} from '../../types/badges';
|
||||||
|
import BadgeImage from '../utils/badge_image';
|
||||||
|
import {markdown} from 'utils/markdown';
|
||||||
|
|
||||||
|
import './user_badge_row.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
badge: UserBadge;
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
onClick: (badge: UserBadge) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserBadgeRow: React.FC<Props> = ({badge, onClick, isCurrentUser}: Props) => {
|
||||||
|
const time = new Date(badge.time);
|
||||||
|
let reason = null;
|
||||||
|
if (badge.reason) {
|
||||||
|
reason = (<div className='badge-user-reason'>{'Why? ' + badge.reason}</div>);
|
||||||
|
}
|
||||||
|
let setStatus = null;
|
||||||
|
if (isCurrentUser && badge.image_type === 'emoji') {
|
||||||
|
setStatus = (
|
||||||
|
<div className='user-badge-set-status'>
|
||||||
|
<a
|
||||||
|
onClick={() => {
|
||||||
|
const c = new Client4();
|
||||||
|
c.updateCustomStatus({emoji: badge.image, text: badge.name});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{'Set status to this badge'}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className='UserBadgesRow'>
|
||||||
|
<a onClick={() => onClick(badge)}>
|
||||||
|
<span className='user-badge-icon'>
|
||||||
|
<BadgeImage
|
||||||
|
badge={badge}
|
||||||
|
size={32}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div className='user-badge-text'>
|
||||||
|
<div className='user-badge-name'>{badge.name}</div>
|
||||||
|
<div className='user-badge-description'>{markdown(badge.description)}</div>
|
||||||
|
{reason}
|
||||||
|
<div className='user-badge-type'>{'Type: ' + badge.type_name}</div>
|
||||||
|
<div className='user-badge-granted-by'>{`Granted by: ${badge.granted_by_name}`}</div>
|
||||||
|
<div className='user-badge-granted-at'>{`Granted at: ${time.toDateString()}`}</div>
|
||||||
|
{setStatus}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserBadgeRow;
|
||||||
6
webapp/src/components/rhs/user_badges.scss
Normal file
6
webapp/src/components/rhs/user_badges.scss
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.UserBadges {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
122
webapp/src/components/rhs/user_badges.tsx
Normal file
122
webapp/src/components/rhs/user_badges.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {UserProfile} from 'mattermost-redux/types/users';
|
||||||
|
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||||
|
|
||||||
|
import {BadgeID, UserBadge} from '../../types/badges';
|
||||||
|
import Client from '../../client/api';
|
||||||
|
|
||||||
|
import {RHSState} from 'types/general';
|
||||||
|
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL} from '../../constants';
|
||||||
|
|
||||||
|
import UserBadgeRow from './user_badge_row';
|
||||||
|
import RHSScrollbars from './rhs_scrollbars';
|
||||||
|
|
||||||
|
import './user_badges.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
isCurrentUser: boolean;
|
||||||
|
user: UserProfile | null;
|
||||||
|
actions: {
|
||||||
|
setRHSView: (view: RHSState) => void;
|
||||||
|
setRHSBadge: (badge: BadgeID | null) => void;
|
||||||
|
getCustomEmojisByName: (names: string[]) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
loading: boolean;
|
||||||
|
badges?: UserBadge[];
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserBadges extends React.PureComponent<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (!this.props.user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const c = new Client();
|
||||||
|
c.getUserBadges(this.props.user.id).then((badges) => {
|
||||||
|
this.setState({badges, loading: false});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||||
|
if (this.state.badges !== prevState.badges) {
|
||||||
|
const names: string[] = [];
|
||||||
|
this.state.badges?.forEach((badge) => {
|
||||||
|
if (badge.image_type === IMAGE_TYPE_EMOJI) {
|
||||||
|
names.push(badge.image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const toLoad = names.filter((v) => !systemEmojis.has(v));
|
||||||
|
this.props.actions.getCustomEmojisByName(toLoad);
|
||||||
|
}
|
||||||
|
if (this.props.user?.id === prevProps.user?.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.props.user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = new Client();
|
||||||
|
if (!this.state.loading) {
|
||||||
|
this.setState({loading: true});
|
||||||
|
}
|
||||||
|
|
||||||
|
c.getUserBadges(this.props.user.id).then((badges) => {
|
||||||
|
this.setState({badges, loading: false});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onBadgeClick = (badge: UserBadge) => {
|
||||||
|
this.props.actions.setRHSBadge(badge.id);
|
||||||
|
this.props.actions.setRHSView(RHS_STATE_DETAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.props.user) {
|
||||||
|
return (<div>{'User not found.'}</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.loading) {
|
||||||
|
return (<div>{'Loading...'}</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.state.badges || this.state.badges.length === 0) {
|
||||||
|
return (<div>{'No badges yet.'}</div>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = this.state.badges.map((badge) => {
|
||||||
|
return (
|
||||||
|
<UserBadgeRow
|
||||||
|
isCurrentUser={this.props.isCurrentUser}
|
||||||
|
key={badge.time}
|
||||||
|
badge={badge}
|
||||||
|
onClick={this.onBadgeClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
let title = 'My badges';
|
||||||
|
if (!this.props.isCurrentUser) {
|
||||||
|
title = `@${this.props.user.username}'s badges`;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className='UserBadges'>
|
||||||
|
<div><b>{title}</b></div>
|
||||||
|
<RHSScrollbars>{content}</RHSScrollbars>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default UserBadges;
|
||||||
13
webapp/src/components/rhs/user_row.scss
Normal file
13
webapp/src/components/rhs/user_row.scss
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
.UserRow {
|
||||||
|
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||||
|
border-radius: 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
.badge-user-username {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.badge-user-granted-at {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
39
webapp/src/components/rhs/user_row.tsx
Normal file
39
webapp/src/components/rhs/user_row.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {useSelector} from 'react-redux';
|
||||||
|
import {getUser} from 'mattermost-redux/selectors/entities/users';
|
||||||
|
import {GlobalState} from 'mattermost-redux/types/store';
|
||||||
|
import {UserProfile} from 'mattermost-redux/types/users';
|
||||||
|
|
||||||
|
import {Ownership} from '../../types/badges';
|
||||||
|
|
||||||
|
import './user_row.scss';
|
||||||
|
type Props = {
|
||||||
|
ownership: Ownership;
|
||||||
|
onClick: (user: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserBadgeRow: React.FC<Props> = ({ownership, onClick}: Props) => {
|
||||||
|
const user = useSelector<GlobalState, UserProfile>((state) => getUser(state, ownership.user));
|
||||||
|
const grantedBy = useSelector<GlobalState, UserProfile>((state) => getUser(state, ownership.granted_by));
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let grantedByName = 'unknown';
|
||||||
|
if (grantedBy) {
|
||||||
|
grantedByName = '@' + grantedBy.username;
|
||||||
|
}
|
||||||
|
|
||||||
|
const time = new Date(ownership.time);
|
||||||
|
return (
|
||||||
|
<div className='UserRow'>
|
||||||
|
<div className='badge-user-username'><a onClick={() => onClick(ownership.user)}>{`@${user.username}`}</a></div>
|
||||||
|
<div className='badge-user-granted-by'>{`Granted by: ${grantedByName}`}</div>
|
||||||
|
<div className='badge-user-granted-at'>{`Granted at: ${time.toDateString()}`}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserBadgeRow;
|
||||||
41
webapp/src/components/user_popover/badge_list.scss
Normal file
41
webapp/src/components/user_popover/badge_list.scss
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
#badgePlugin {
|
||||||
|
#contentContainer {
|
||||||
|
display: flex;
|
||||||
|
align-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#showMoreButton {
|
||||||
|
background-color: var(--center-channel-bg);
|
||||||
|
color: rgba(var(--center-channel-color), 0.56);
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--center-channel-color), 0.08);
|
||||||
|
color: rgba(var(--center-channel-color), 0.72);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#grantBadgeButton {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 0;
|
||||||
|
border: 0;
|
||||||
|
background-color: var(--center-channel-bg);
|
||||||
|
color: var(--button-bg);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
gap: 6px;
|
||||||
|
line-height: 10px;
|
||||||
|
|
||||||
|
.fa-plus-circle {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
189
webapp/src/components/user_popover/badge_list.tsx
Normal file
189
webapp/src/components/user_popover/badge_list.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import {UserProfile} from 'mattermost-redux/types/users';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {Tooltip, OverlayTrigger} from 'react-bootstrap';
|
||||||
|
|
||||||
|
import {GlobalState} from 'mattermost-redux/types/store';
|
||||||
|
|
||||||
|
import {systemEmojis} from 'mattermost-redux/actions/emojis';
|
||||||
|
|
||||||
|
import {BadgeID, UserBadge} from 'types/badges';
|
||||||
|
import Client from 'client/api';
|
||||||
|
import BadgeImage from '../utils/badge_image';
|
||||||
|
import {RHSState} from 'types/general';
|
||||||
|
import {IMAGE_TYPE_EMOJI, RHS_STATE_DETAIL, RHS_STATE_MY, RHS_STATE_OTHER} from '../../constants';
|
||||||
|
import {markdown} from 'utils/markdown';
|
||||||
|
|
||||||
|
import './badge_list.scss';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
debug: GlobalState;
|
||||||
|
user: UserProfile;
|
||||||
|
currentUserID: string;
|
||||||
|
openRHS: (() => void) | null;
|
||||||
|
hide: () => void;
|
||||||
|
status?: string;
|
||||||
|
actions: {
|
||||||
|
setRHSView: (view: RHSState) => Promise<void>;
|
||||||
|
setRHSBadge: (id: BadgeID | null) => Promise<void>;
|
||||||
|
setRHSUser: (id: string | null) => Promise<void>;
|
||||||
|
openGrant: (user?: string, badge?: string) => Promise<void>;
|
||||||
|
getCustomEmojisByName: (names: string[]) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
badges?: UserBadge[];
|
||||||
|
loaded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BADGES = 7;
|
||||||
|
const BADGE_SIZE = 24;
|
||||||
|
|
||||||
|
class BadgeList extends React.PureComponent<Props, State> {
|
||||||
|
constructor(props: Props) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const c = new Client();
|
||||||
|
c.getUserBadges(this.props.user.id).then((badges) => {
|
||||||
|
this.setState({badges, loaded: true});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps: Props, prevState: State) {
|
||||||
|
if (this.state.badges !== prevState.badges) {
|
||||||
|
const nBadges = this.state.badges?.length || 0;
|
||||||
|
const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES;
|
||||||
|
const names: string[] = [];
|
||||||
|
for (let i = 0; i < toShow; i++) {
|
||||||
|
const badge = this.state.badges![i];
|
||||||
|
if (badge.image_type === IMAGE_TYPE_EMOJI) {
|
||||||
|
names.push(badge.image);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const toLoad = names.filter((v) => !systemEmojis.has(v));
|
||||||
|
this.props.actions.getCustomEmojisByName(toLoad);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMoreClick = () => {
|
||||||
|
if (!this.props.openRHS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.props.currentUserID === this.props.user.id) {
|
||||||
|
this.props.actions.setRHSView(RHS_STATE_MY);
|
||||||
|
this.props.openRHS();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.actions.setRHSUser(this.props.user.id);
|
||||||
|
this.props.actions.setRHSView(RHS_STATE_OTHER);
|
||||||
|
this.props.openRHS();
|
||||||
|
this.props.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
onBadgeClick = (badge: UserBadge) => {
|
||||||
|
if (!this.props.openRHS) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.actions.setRHSBadge(badge.id);
|
||||||
|
this.props.actions.setRHSView(RHS_STATE_DETAIL);
|
||||||
|
this.props.openRHS();
|
||||||
|
this.props.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
onGrantClick = () => {
|
||||||
|
this.props.actions.openGrant(this.props.user.username);
|
||||||
|
this.props.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const nBadges = this.state.badges?.length || 0;
|
||||||
|
const toShow = nBadges < MAX_BADGES ? nBadges : MAX_BADGES;
|
||||||
|
|
||||||
|
const content: React.ReactNode[] = [];
|
||||||
|
for (let i = 0; i < toShow; i++) {
|
||||||
|
const badge = this.state.badges![i];
|
||||||
|
const time = new Date(badge.time);
|
||||||
|
let reason = null;
|
||||||
|
if (badge.reason) {
|
||||||
|
reason = (<div>{'Why? ' + badge.reason}</div>);
|
||||||
|
}
|
||||||
|
const badgeComponent = (
|
||||||
|
<OverlayTrigger
|
||||||
|
overlay={<Tooltip id='badgeTooltip'>
|
||||||
|
<div>{badge.name}</div>
|
||||||
|
<div>{markdown(badge.description)}</div>
|
||||||
|
{reason}
|
||||||
|
<div>{`Granted by: ${badge.granted_by_name}`}</div>
|
||||||
|
<div>{`Granted at: ${time.toDateString()}`}</div>
|
||||||
|
</Tooltip>}
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<a onClick={() => this.onBadgeClick(badge)}>
|
||||||
|
<BadgeImage
|
||||||
|
badge={badge}
|
||||||
|
size={BADGE_SIZE}
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
content.push(badgeComponent);
|
||||||
|
}
|
||||||
|
let andMore: React.ReactNode = null;
|
||||||
|
if (nBadges > MAX_BADGES) {
|
||||||
|
andMore = (
|
||||||
|
<OverlayTrigger
|
||||||
|
overlay={<Tooltip id='badgeMoreTooltip'>
|
||||||
|
{`and ${nBadges - MAX_BADGES} more. Click to see all.`}
|
||||||
|
</Tooltip>}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
id='showMoreButton'
|
||||||
|
onClick={this.onMoreClick}
|
||||||
|
>
|
||||||
|
<span className={'fa fa-angle-right'}/>
|
||||||
|
</button>
|
||||||
|
</OverlayTrigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const maxWidth = (MAX_BADGES * BADGE_SIZE) + 30;
|
||||||
|
let loading: React.ReactNode = null;
|
||||||
|
if (!this.state.loaded) {
|
||||||
|
loading = (
|
||||||
|
|
||||||
|
// Reserve enough height one row of badges and the "and more" button
|
||||||
|
<div style={{height: BADGE_SIZE, minWidth: 66, maxWidth}}>
|
||||||
|
{'Loading...'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div id='badgePlugin'>
|
||||||
|
<div><b>{'Badges'}</b></div>
|
||||||
|
<div id='contentContainer' >
|
||||||
|
{content}
|
||||||
|
{andMore}
|
||||||
|
</div>
|
||||||
|
{loading}
|
||||||
|
<button
|
||||||
|
id='grantBadgeButton'
|
||||||
|
onClick={this.onGrantClick}
|
||||||
|
>
|
||||||
|
<span className={'fa fa-plus-circle'}/>
|
||||||
|
{'Grant badge'}
|
||||||
|
</button>
|
||||||
|
<hr className='divider divider--expanded'/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BadgeList;
|
||||||
49
webapp/src/components/user_popover/index.ts
Normal file
49
webapp/src/components/user_popover/index.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See License for license information.
|
||||||
|
|
||||||
|
import {connect} from 'react-redux';
|
||||||
|
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
|
||||||
|
|
||||||
|
import {GlobalState} from 'mattermost-redux/types/store';
|
||||||
|
|
||||||
|
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||||
|
|
||||||
|
import {getCustomEmojisByName} from 'mattermost-redux/actions/emojis';
|
||||||
|
|
||||||
|
import {setRHSView, setRHSBadge, setRHSUser, openGrant} from '../../actions/actions';
|
||||||
|
|
||||||
|
import {getShowRHS} from 'selectors';
|
||||||
|
import {RHSState} from 'types/general';
|
||||||
|
import {BadgeID} from 'types/badges';
|
||||||
|
|
||||||
|
import BadgeList from './badge_list';
|
||||||
|
|
||||||
|
function mapStateToProps(state: GlobalState) {
|
||||||
|
return {
|
||||||
|
openRHS: getShowRHS(state),
|
||||||
|
currentUserID: getCurrentUserId(state),
|
||||||
|
debug: state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type Actions = {
|
||||||
|
setRHSView: (view: RHSState) => Promise<void>;
|
||||||
|
setRHSBadge: (id: BadgeID | null) => Promise<void>;
|
||||||
|
setRHSUser: (id: string | null) => Promise<void>;
|
||||||
|
openGrant: (user?: string, badge?: string) => Promise<void>;
|
||||||
|
getCustomEmojisByName: (names: string[]) => Promise<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDispatchToProps(dispatch: Dispatch) {
|
||||||
|
return {
|
||||||
|
actions: bindActionCreators<ActionCreatorsMapObject, Actions>({
|
||||||
|
setRHSView,
|
||||||
|
setRHSBadge,
|
||||||
|
setRHSUser,
|
||||||
|
openGrant,
|
||||||
|
getCustomEmojisByName,
|
||||||
|
}, dispatch),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(BadgeList);
|
||||||
34
webapp/src/components/utils/badge_image.tsx
Normal file
34
webapp/src/components/utils/badge_image.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {Badge} from '../../types/badges';
|
||||||
|
import RenderEmoji from '../utils/emoji';
|
||||||
|
import {IMAGE_TYPE_ABSOLUTE_URL, IMAGE_TYPE_EMOJI} from '../../constants';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
badge: Badge;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BadgeImage: React.FC<Props> = ({badge, size}: Props) => {
|
||||||
|
switch (badge.image_type) {
|
||||||
|
case IMAGE_TYPE_EMOJI:
|
||||||
|
return (
|
||||||
|
<RenderEmoji
|
||||||
|
emojiName={badge.image}
|
||||||
|
size={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case IMAGE_TYPE_ABSOLUTE_URL:
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={badge.image}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BadgeImage;
|
||||||
55
webapp/src/components/utils/emoji.tsx
Normal file
55
webapp/src/components/utils/emoji.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
import React from 'react';
|
||||||
|
import {useSelector} from 'react-redux';
|
||||||
|
|
||||||
|
import {GlobalState} from 'mattermost-redux/types/store';
|
||||||
|
|
||||||
|
import {getEmojiMap} from 'selectors';
|
||||||
|
import {getEmojiImageUrl} from 'utils/emoji';
|
||||||
|
|
||||||
|
interface ComponentProps {
|
||||||
|
emojiName: string;
|
||||||
|
size?: number;
|
||||||
|
emojiStyle?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RenderEmoji = ({emojiName, emojiStyle, size}: ComponentProps) => {
|
||||||
|
const emojiMap = useSelector((state: GlobalState) => getEmojiMap(state));
|
||||||
|
|
||||||
|
if (!emojiName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emojiFromMap = emojiMap.get(emojiName);
|
||||||
|
if (!emojiFromMap) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const emojiImageUrl = getEmojiImageUrl(emojiFromMap);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className='emoticon'
|
||||||
|
data-emoticon={emojiName}
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${emojiImageUrl})`,
|
||||||
|
backgroundSize: size,
|
||||||
|
height: size,
|
||||||
|
width: size,
|
||||||
|
maxHeight: size,
|
||||||
|
maxWidth: size,
|
||||||
|
minHeight: size,
|
||||||
|
minWidth: size,
|
||||||
|
...emojiStyle,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
RenderEmoji.defaultProps = {
|
||||||
|
emoji: '',
|
||||||
|
emojiStyle: {},
|
||||||
|
size: 16,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(RenderEmoji);
|
||||||
17
webapp/src/constants.ts
Normal file
17
webapp/src/constants.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import {PluginState, RHSState} from 'types/general';
|
||||||
|
|
||||||
|
export const IMAGE_TYPE_EMOJI = 'emoji';
|
||||||
|
export const IMAGE_TYPE_RELATIVE_URL = 'rel_url';
|
||||||
|
export const IMAGE_TYPE_ABSOLUTE_URL = 'abs_url';
|
||||||
|
|
||||||
|
export const RHS_STATE_MY: RHSState = 'my';
|
||||||
|
export const RHS_STATE_OTHER: RHSState = 'other';
|
||||||
|
export const RHS_STATE_ALL: RHSState = 'all';
|
||||||
|
export const RHS_STATE_DETAIL: RHSState = 'detail';
|
||||||
|
|
||||||
|
export const initialState: PluginState = {
|
||||||
|
showRHS: null,
|
||||||
|
rhsView: RHS_STATE_MY,
|
||||||
|
rhsBadge: null,
|
||||||
|
rhsUser: null,
|
||||||
|
};
|
||||||
93
webapp/src/index.tsx
Normal file
93
webapp/src/index.tsx
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import {Store} from 'redux';
|
||||||
|
|
||||||
|
import {GlobalState} from 'mattermost-redux/types/store';
|
||||||
|
|
||||||
|
import {GenericAction} from 'mattermost-redux/types/actions';
|
||||||
|
|
||||||
|
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import {openAddSubscription, openCreateBadge, openCreateType, openRemoveSubscription, setRHSView, setShowRHSAction} from 'actions/actions';
|
||||||
|
|
||||||
|
import UserBadges from 'components/rhs';
|
||||||
|
|
||||||
|
import ChannelHeaderButton from 'components/channel_header_button';
|
||||||
|
|
||||||
|
import Reducer from './reducers';
|
||||||
|
|
||||||
|
import manifest from './manifest';
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-unresolved
|
||||||
|
import {PluginRegistry} from './types/mattermost-webapp';
|
||||||
|
import BadgeList from './components/user_popover/';
|
||||||
|
import {RHS_STATE_ALL} from './constants';
|
||||||
|
|
||||||
|
export default class Plugin {
|
||||||
|
public async initialize(registry: PluginRegistry, store: Store<GlobalState, GenericAction>) {
|
||||||
|
registry.registerReducer(Reducer);
|
||||||
|
|
||||||
|
registry.registerPopoverUserAttributesComponent(BadgeList);
|
||||||
|
|
||||||
|
const {showRHSPlugin, toggleRHSPlugin} = registry.registerRightHandSidebarComponent(UserBadges, 'Badges');
|
||||||
|
store.dispatch(setShowRHSAction(() => store.dispatch(showRHSPlugin)));
|
||||||
|
|
||||||
|
const toggleRHS = () => {
|
||||||
|
store.dispatch(setRHSView(RHS_STATE_ALL));
|
||||||
|
store.dispatch(toggleRHSPlugin);
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.registerChannelHeaderButtonAction(
|
||||||
|
<ChannelHeaderButton/>,
|
||||||
|
toggleRHS,
|
||||||
|
'Badges',
|
||||||
|
'Open the list of all badges.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (registry.registerAppBarComponent) {
|
||||||
|
const siteUrl = getConfig(store.getState())?.SiteURL || '';
|
||||||
|
const iconURL = `${siteUrl}/plugins/${manifest.id}/public/app-bar-icon.png`;
|
||||||
|
registry.registerAppBarComponent(
|
||||||
|
iconURL,
|
||||||
|
toggleRHS,
|
||||||
|
'Open the list of all badges.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.registerMainMenuAction(
|
||||||
|
'Create badge',
|
||||||
|
() => {
|
||||||
|
store.dispatch(openCreateBadge() as any);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
registry.registerMainMenuAction(
|
||||||
|
'Create badge type',
|
||||||
|
() => {
|
||||||
|
store.dispatch(openCreateType() as any);
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
registry.registerChannelHeaderMenuAction(
|
||||||
|
'Add badge subscription',
|
||||||
|
() => {
|
||||||
|
store.dispatch(openAddSubscription() as any);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
registry.registerChannelHeaderMenuAction(
|
||||||
|
'Remove badge subscription',
|
||||||
|
() => {
|
||||||
|
store.dispatch(openRemoveSubscription() as any);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
registerPlugin(id: string, plugin: Plugin): void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.registerPlugin(manifest.id, new Plugin());
|
||||||
13
webapp/src/manifest.test.tsx
Normal file
13
webapp/src/manifest.test.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import manifest, {id, version} from './manifest';
|
||||||
|
|
||||||
|
test('Plugin manifest, id and version are defined', () => {
|
||||||
|
expect(manifest).toBeDefined();
|
||||||
|
expect(manifest.id).toBeDefined();
|
||||||
|
expect(manifest.version).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// To ease migration, verify separate export of id and version.
|
||||||
|
test('Plugin id and version are defined', () => {
|
||||||
|
expect(id).toBeDefined();
|
||||||
|
expect(version).toBeDefined();
|
||||||
|
});
|
||||||
5
webapp/src/manifest.ts
Normal file
5
webapp/src/manifest.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import manifest from '../../plugin.json';
|
||||||
|
|
||||||
|
export default manifest;
|
||||||
|
export const id = manifest.id;
|
||||||
|
export const version = manifest.version;
|
||||||
49
webapp/src/reducers/index.ts
Normal file
49
webapp/src/reducers/index.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import {combineReducers} from 'redux';
|
||||||
|
|
||||||
|
import {GenericAction} from 'mattermost-redux/types/actions';
|
||||||
|
|
||||||
|
import ActionTypes from '../action_types';
|
||||||
|
import {RHS_STATE_MY} from '../constants';
|
||||||
|
|
||||||
|
function showRHS(state = null, action: GenericAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionTypes.RECEIVED_SHOW_RHS_ACTION:
|
||||||
|
return action.showRHSPluginAction;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rhsView(state = RHS_STATE_MY, action: GenericAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionTypes.RECEIVED_RHS_VIEW:
|
||||||
|
return action.data;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rhsUser(state = null, action: GenericAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionTypes.RECEIVED_RHS_USER:
|
||||||
|
return action.data;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rhsBadge(state = null, action: GenericAction) {
|
||||||
|
switch (action.type) {
|
||||||
|
case ActionTypes.RECEIVED_RHS_BADGE:
|
||||||
|
return action.data;
|
||||||
|
default:
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default combineReducers({
|
||||||
|
showRHS,
|
||||||
|
rhsView,
|
||||||
|
rhsUser,
|
||||||
|
rhsBadge,
|
||||||
|
});
|
||||||
45
webapp/src/selectors/index.ts
Normal file
45
webapp/src/selectors/index.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import {createSelector} from 'reselect';
|
||||||
|
|
||||||
|
import {getCustomEmojisByName} from 'mattermost-redux/selectors/entities/emojis';
|
||||||
|
|
||||||
|
import EmojiMap from '../utils/emoji_map';
|
||||||
|
import {id as pluginId} from '../manifest';
|
||||||
|
import {initialState} from '../constants';
|
||||||
|
import {PluginState} from 'types/general';
|
||||||
|
|
||||||
|
const getPluginState = (state: any): PluginState => state['plugins-' + pluginId] || initialState;
|
||||||
|
|
||||||
|
export const getEmojiMap = createSelector(
|
||||||
|
getCustomEmojisByName,
|
||||||
|
(customEmojisByName) => {
|
||||||
|
return new EmojiMap(customEmojisByName);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getShowRHS = createSelector(
|
||||||
|
getPluginState,
|
||||||
|
(state) => {
|
||||||
|
return state.showRHS;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getRHSView = createSelector(
|
||||||
|
getPluginState,
|
||||||
|
(state) => {
|
||||||
|
return state.rhsView;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getRHSUser = createSelector(
|
||||||
|
getPluginState,
|
||||||
|
(state) => {
|
||||||
|
return state.rhsUser;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getRHSBadge = createSelector(
|
||||||
|
getPluginState,
|
||||||
|
(state) => {
|
||||||
|
return state.rhsBadge;
|
||||||
|
},
|
||||||
|
);
|
||||||
45
webapp/src/types/badges.ts
Normal file
45
webapp/src/types/badges.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
export type Badge = {
|
||||||
|
id: BadgeID;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
image: string;
|
||||||
|
image_type: BadgeImageType;
|
||||||
|
multiple: boolean;
|
||||||
|
type: BadgeType;
|
||||||
|
created_by: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Ownership = {
|
||||||
|
user: string;
|
||||||
|
granted_by: string;
|
||||||
|
badge: BadgeID;
|
||||||
|
reason: string;
|
||||||
|
time: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BadgeID = number;
|
||||||
|
export type BadgeType = number
|
||||||
|
export type BadgeImageType = string;
|
||||||
|
|
||||||
|
export type UserBadge = Badge & Ownership & {
|
||||||
|
granted_by_name: string;
|
||||||
|
type_name: string;
|
||||||
|
};
|
||||||
|
export type BadgeDetails = Badge & {
|
||||||
|
owners: OwnershipList;
|
||||||
|
created_by_username: string;
|
||||||
|
type_name: string;
|
||||||
|
}
|
||||||
|
export type AllBadgesBadge = Badge & {
|
||||||
|
granted: number;
|
||||||
|
granted_times: number;
|
||||||
|
type_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type OwnershipList = Ownership[]
|
||||||
|
|
||||||
|
export type BadgeTypeDefinition = {
|
||||||
|
id: BadgeType;
|
||||||
|
name: string;
|
||||||
|
frame: string;
|
||||||
|
}
|
||||||
10
webapp/src/types/general.ts
Normal file
10
webapp/src/types/general.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import {BadgeID} from './badges';
|
||||||
|
|
||||||
|
export type RHSState = string;
|
||||||
|
|
||||||
|
export type PluginState = {
|
||||||
|
showRHS: (() => void)| null;
|
||||||
|
rhsView: RHSState;
|
||||||
|
rhsUser: string | null;
|
||||||
|
rhsBadge: BadgeID | null;
|
||||||
|
}
|
||||||
24
webapp/src/types/mattermost-webapp/index.d.ts
vendored
Normal file
24
webapp/src/types/mattermost-webapp/index.d.ts
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import {GenericAction} from 'mattermost-redux/types/actions';
|
||||||
|
import {Channel, ChannelMembership} from 'mattermost-redux/types/channels';
|
||||||
|
import React from 'react';
|
||||||
|
import {Reducer} from 'redux';
|
||||||
|
|
||||||
|
export interface PluginRegistry {
|
||||||
|
registerPostTypeComponent(typeName: string, component: React.ElementType);
|
||||||
|
registerPopoverUserAttributesComponent(component: React.ElementType);
|
||||||
|
registerRightHandSidebarComponent(component: React.ElementType, name: string): RegisterRightHandSidebarComponentReturn;
|
||||||
|
registerReducer(reducer: Reducer);
|
||||||
|
registerChannelHeaderButtonAction(icon: React.ReactNode, action: () => void, dropdownText: string, tooltip: string);
|
||||||
|
registerMainMenuAction(text: React.ReactNode, action: () => void, mobileIcon: React.ReactNode);
|
||||||
|
registerChannelHeaderMenuAction(text: string, action: (channelID: string) => void);
|
||||||
|
registerAppBarComponent(iconURL: string, action: (channel: Channel, member: ChannelMembership) => void, tooltipText: React.ReactNode)
|
||||||
|
|
||||||
|
// Add more if needed from https://developers.mattermost.com/extend/plugins/webapp/reference
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRightHandSidebarComponentReturn = {
|
||||||
|
id: string;
|
||||||
|
showRHSPlugin: GenericAction;
|
||||||
|
hideRHSPlugin: GenericAction;
|
||||||
|
toggleRHSPlugin: GenericAction;
|
||||||
|
}
|
||||||
BIN
webapp/src/utils/apple-sheet.png
Normal file
BIN
webapp/src/utils/apple-sheet.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 MiB |
1
webapp/src/utils/emoji.json
Normal file
1
webapp/src/utils/emoji.json
Normal file
File diff suppressed because one or more lines are too long
59
webapp/src/utils/emoji.tsx
Normal file
59
webapp/src/utils/emoji.tsx
Normal file
File diff suppressed because one or more lines are too long
70
webapp/src/utils/emoji_map.ts
Normal file
70
webapp/src/utils/emoji_map.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||||
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
|
import {CustomEmoji} from 'mattermost-redux/types/emojis';
|
||||||
|
|
||||||
|
import * as Emoji from './emoji';
|
||||||
|
|
||||||
|
export default class EmojiMap {
|
||||||
|
private customEmojis: Map<string, CustomEmoji>;
|
||||||
|
private customEmojisArray: [string, CustomEmoji][];
|
||||||
|
|
||||||
|
constructor(customEmojis: Map<string, CustomEmoji>) {
|
||||||
|
this.customEmojis = customEmojis;
|
||||||
|
|
||||||
|
// Store customEmojis to an array so we can iterate it more easily
|
||||||
|
this.customEmojisArray = [...customEmojis];
|
||||||
|
}
|
||||||
|
|
||||||
|
has(name: string) {
|
||||||
|
return Emoji.EmojiIndicesByAlias.has(name) || this.customEmojis.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSystemEmoji(name: string) {
|
||||||
|
return Emoji.EmojiIndicesByAlias.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasUnicode(codepoint: string) {
|
||||||
|
return Emoji.EmojiIndicesByUnicode.has(codepoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
get(name: string) {
|
||||||
|
if (Emoji.EmojiIndicesByAlias.has(name)) {
|
||||||
|
return Emoji.Emojis[Emoji.EmojiIndicesByAlias.get(name)!];
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.customEmojis.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUnicode(codepoint: string) {
|
||||||
|
return Emoji.Emojis[Emoji.EmojiIndicesByUnicode.get(codepoint)!];
|
||||||
|
}
|
||||||
|
|
||||||
|
[Symbol.iterator]() {
|
||||||
|
const customEmojisArray = this.customEmojisArray;
|
||||||
|
|
||||||
|
return {
|
||||||
|
systemIndex: 0,
|
||||||
|
customIndex: 0,
|
||||||
|
next() {
|
||||||
|
if (this.systemIndex < Emoji.Emojis.length) {
|
||||||
|
const emoji = Emoji.Emojis[this.systemIndex];
|
||||||
|
|
||||||
|
this.systemIndex += 1;
|
||||||
|
|
||||||
|
return {value: [(emoji as any).short_names[0], emoji]};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.customIndex < customEmojisArray.length) {
|
||||||
|
const emoji = customEmojisArray[this.customIndex][1] as any;
|
||||||
|
|
||||||
|
this.customIndex += 1;
|
||||||
|
const name = emoji.short_name || emoji.name;
|
||||||
|
return {value: [name, emoji]};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {done: true};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
7
webapp/src/utils/markdown.ts
Normal file
7
webapp/src/utils/markdown.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export function markdown(text: string, siteURL?: string): React.ReactNode {
|
||||||
|
const PostUtils = (window as any).PostUtils; // import the post utilities
|
||||||
|
const htmlFormatedText = PostUtils.formatText(text, {siteURL});
|
||||||
|
return PostUtils.messageHtmlToComponent(htmlFormatedText);
|
||||||
|
}
|
||||||
1
webapp/tests/i18n_mock.json
Normal file
1
webapp/tests/i18n_mock.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user