├── .nvmrc ├── assets ├── .gitkeep ├── postExample.png ├── settingsDialog.png └── channelHeaderDropdown.png ├── webapp ├── i18n │ └── en.json ├── .npmrc ├── tests │ ├── i18n_mock.json │ └── setup.js ├── .gitignore ├── .babelrc ├── src │ ├── manifest.js │ ├── selectors.js │ ├── action_types │ │ └── index.js │ ├── manifest.test.js │ ├── components │ │ └── meeting_settings │ │ │ ├── index.js │ │ │ └── meeting_settings.jsx │ ├── reducer.js │ ├── index.js │ ├── client.js │ └── actions │ │ └── index.js ├── tsconfig.json ├── babel.config.js ├── webpack.config.js ├── package.json └── .eslintrc.json ├── .gitignore ├── server ├── .gitignore ├── main.go ├── utils_test.go ├── plugin_test.go ├── utils.go ├── configuration.go ├── meeting.go ├── plugin.go ├── meeting_test.go └── command.go ├── .gitpod.yml ├── .gitattributes ├── .github ├── dependabot.yml └── workflows │ ├── cd.yml │ ├── ci.yml │ └── codeql-analysis.yml ├── CHANGELOG.md ├── plugin.go ├── .editorconfig ├── plugin.json ├── .golangci.yml ├── go.mod ├── README.md ├── Makefile └── LICENSE /.nvmrc: -------------------------------------------------------------------------------- 1 | 13 2 | -------------------------------------------------------------------------------- /assets/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /webapp/i18n/en.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /webapp/.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /webapp/tests/i18n_mock.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | coverage.txt 2 | dist 3 | -------------------------------------------------------------------------------- /webapp/.gitignore: -------------------------------------------------------------------------------- 1 | .eslintcache 2 | junit.xml 3 | node_modules 4 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | mainConfiguration: https://github.com/mattermost/mattermost-gitpod-config 2 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | server/manifest.go linguist-generated=true 2 | webapp/src/manifest.js linguist-generated=true 3 | -------------------------------------------------------------------------------- /webapp/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | presets: ['@babel/preset-env', '@babel/preset-react', '@babel/typescript'] 3 | } 4 | 5 | -------------------------------------------------------------------------------- /assets/postExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattermost-community/mattermost-plugin-agenda/HEAD/assets/postExample.png -------------------------------------------------------------------------------- /assets/settingsDialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattermost-community/mattermost-plugin-agenda/HEAD/assets/settingsDialog.png -------------------------------------------------------------------------------- /assets/channelHeaderDropdown.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mattermost-community/mattermost-plugin-agenda/HEAD/assets/channelHeaderDropdown.png -------------------------------------------------------------------------------- /webapp/src/manifest.js: -------------------------------------------------------------------------------- 1 | import manifest from '../../plugin.json'; 2 | 3 | export default manifest; 4 | export const id = manifest.id; 5 | export const version = manifest.version; 6 | -------------------------------------------------------------------------------- /server/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/mattermost/mattermost-server/v6/plugin" 5 | ) 6 | 7 | func main() { 8 | plugin.ClientMain(&Plugin{}) 9 | } 10 | -------------------------------------------------------------------------------- /webapp/tests/setup.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2 | // See LICENSE.txt for license information. 3 | 4 | import 'mattermost-webapp/tests/setup'; 5 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Enable version updates for GOLang dependencies 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | on: 3 | push: 4 | tags: 5 | - "v*" 6 | 7 | jobs: 8 | plugin-cd: 9 | uses: mattermost/actions-workflows/.github/workflows/community-plugin-cd.yml@d9defa3e455bdbf889573e112ad8d05b91d66b4c 10 | secrets: inherit 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | 9 | jobs: 10 | plugin-ci: 11 | uses: mattermost/actions-workflows/.github/workflows/community-plugin-ci.yml@139a051e8651e6246e3764fe342297b73120e590 12 | secrets: inherit 13 | -------------------------------------------------------------------------------- /webapp/src/selectors.js: -------------------------------------------------------------------------------- 1 | import {id as pluginId} from './manifest'; 2 | 3 | const getPluginState = (state) => state['plugins-' + pluginId] || {}; 4 | 5 | export const getMeetingSettingsModalState = (state) => getPluginState(state).meetingSettingsModal; 6 | export const getMeetingSettings = (state) => getPluginState(state).meetingSettings; 7 | -------------------------------------------------------------------------------- /webapp/src/action_types/index.js: -------------------------------------------------------------------------------- 1 | import {id as pluginId} from '../manifest'; 2 | 3 | export default { 4 | OPEN_MEETING_SETTINGS_MODAL: pluginId + '_open_meeting_settings_modal', 5 | CLOSE_MEETING_SETTINGS_MODAL: pluginId + '_close_meeting_settings_modal', 6 | RECEIVED_MEETING_SETTINGS: pluginId + '_received_meeting_settings', 7 | }; 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## 0.0.1 - 2018-08-16 8 | ### Added 9 | - Initial release 10 | -------------------------------------------------------------------------------- /plugin.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | _ "embed" // Need to embed manifest file 5 | "encoding/json" 6 | "strings" 7 | 8 | "github.com/mattermost/mattermost-server/v6/model" 9 | ) 10 | 11 | //go:embed plugin.json 12 | var manifestString string 13 | 14 | var Manifest model.Manifest 15 | 16 | func init() { 17 | _ = json.NewDecoder(strings.NewReader(manifestString)).Decode(&Manifest) 18 | } 19 | -------------------------------------------------------------------------------- /webapp/src/manifest.test.js: -------------------------------------------------------------------------------- 1 | import manifest, {id, version} from './manifest'; 2 | 3 | test('Plugin manifest, id and version are defined', () => { 4 | expect(manifest).toBeDefined(); 5 | expect(manifest.id).toBeDefined(); 6 | expect(manifest.version).toBeDefined(); 7 | }); 8 | 9 | // To ease migration, verify separate export of id and version. 10 | test('Plugin id and version are defined', () => { 11 | expect(id).toBeDefined(); 12 | expect(version).toBeDefined(); 13 | }); 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | 11 | [*.go] 12 | indent_style = tab 13 | 14 | [*.{js,jsx,json,html}] 15 | indent_style = space 16 | indent_size = 4 17 | 18 | [webapp/package.json] 19 | indent_size = 2 20 | 21 | [Makefile,*.mk] 22 | indent_style = tab 23 | 24 | [*.md] 25 | indent_style = space 26 | indent_size = 4 27 | trim_trailing_whitespace = false 28 | 29 | -------------------------------------------------------------------------------- /webapp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "strictNullChecks": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "module": "commonjs", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "experimentalDecorators": true, 22 | "jsx": "react", 23 | "baseUrl": "." 24 | }, 25 | "include": [ 26 | "./**/*" 27 | ], 28 | "exclude": [ 29 | "dist", 30 | "!node_modules/@types" 31 | ] 32 | } 33 | -------------------------------------------------------------------------------- /webapp/src/components/meeting_settings/index.js: -------------------------------------------------------------------------------- 1 | import {connect} from 'react-redux'; 2 | import {bindActionCreators} from 'redux'; 3 | 4 | import {getMeetingSettingsModalState, getMeetingSettings} from 'selectors'; 5 | import {closeMeetingSettingsModal, fetchMeetingSettings, saveMeetingSettings} from 'actions'; 6 | 7 | import MeetingSettingsModal from './meeting_settings'; 8 | 9 | function mapStateToProps(state) { 10 | return { 11 | visible: getMeetingSettingsModalState(state).visible, 12 | channelId: getMeetingSettingsModalState(state).channelId, 13 | meeting: getMeetingSettings(state).meeting, 14 | saveMeetingSettings, 15 | }; 16 | } 17 | 18 | const mapDispatchToProps = (dispatch) => bindActionCreators({ 19 | close: closeMeetingSettingsModal, 20 | fetchMeetingSettings, 21 | }, dispatch); 22 | 23 | export default connect(mapStateToProps, mapDispatchToProps)(MeetingSettingsModal); 24 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "com.mattermost.agenda", 3 | "name": "Agenda", 4 | "description": "Plugin to handle meeting agendas for Mattermost channels.", 5 | "homepage_url": "https://github.com/mattermost/mattermost-plugin-agenda", 6 | "support_url": "https://github.com/mattermost/mattermost-plugin-agenda/issues", 7 | "release_notes_url": "https://github.com/mattermost/mattermost-plugin-agenda/releases/tag/v0.2.2", 8 | "version": "0.2.2", 9 | "min_server_version": "5.26.0", 10 | "server": { 11 | "executables": { 12 | "linux-amd64": "server/dist/plugin-linux-amd64", 13 | "darwin-amd64": "server/dist/plugin-darwin-amd64", 14 | "windows-amd64": "server/dist/plugin-windows-amd64.exe" 15 | }, 16 | "executable": "" 17 | }, 18 | "webapp": { 19 | "bundle_path": "webapp/dist/main.js" 20 | }, 21 | "settings_schema": { 22 | "header": "", 23 | "footer": "", 24 | "settings": [] 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /webapp/src/reducer.js: -------------------------------------------------------------------------------- 1 | import {combineReducers} from 'redux'; 2 | 3 | import ActionTypes from './action_types'; 4 | 5 | const meetingSettingsModal = (state = {visible: false, channelId: ''}, action) => { 6 | switch (action.type) { 7 | case ActionTypes.OPEN_MEETING_SETTINGS_MODAL: 8 | return { 9 | ...state, 10 | visible: true, 11 | channelId: action.channelId, 12 | }; 13 | case ActionTypes.CLOSE_MEETING_SETTINGS_MODAL: 14 | return { 15 | ...state, 16 | visible: false, 17 | channelId: '', 18 | }; 19 | default: 20 | return state; 21 | } 22 | }; 23 | 24 | function meetingSettings(state = {}, action) { 25 | switch (action.type) { 26 | case ActionTypes.RECEIVED_MEETING_SETTINGS: { 27 | return { 28 | ...state, 29 | meeting: action.data, 30 | }; 31 | } 32 | default: 33 | return state; 34 | } 35 | } 36 | 37 | export default combineReducers({ 38 | meetingSettingsModal, 39 | meetingSettings, 40 | }); 41 | 42 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | modules-download-mode: readonly 4 | 5 | linters-settings: 6 | gofmt: 7 | simplify: true 8 | goimports: 9 | local-prefixes: github.com/mattermost/mattermost-plugin-agenda 10 | govet: 11 | check-shadowing: true 12 | enable-all: true 13 | disable: 14 | - fieldalignment 15 | misspell: 16 | locale: US 17 | 18 | linters: 19 | disable-all: true 20 | enable: 21 | - bodyclose 22 | - errcheck 23 | - gocritic 24 | - gofmt 25 | - goimports 26 | - gosec 27 | - gosimple 28 | - govet 29 | - ineffassign 30 | - misspell 31 | - nakedret 32 | - revive 33 | - staticcheck 34 | - stylecheck 35 | - typecheck 36 | - unconvert 37 | - unused 38 | - whitespace 39 | 40 | issues: 41 | exclude-rules: 42 | - path: server/manifest.go 43 | linters: 44 | - unused 45 | - path: server/configuration.go 46 | linters: 47 | - unused 48 | - path: _test\.go 49 | linters: 50 | - bodyclose 51 | - scopelint # https://github.com/kyoh86/scopelint/issues/4 52 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ master ] 9 | schedule: 10 | - cron: '30 0 * * 0' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'go', 'javascript' ] 25 | 26 | steps: 27 | - name: Checkout repository 28 | uses: actions/checkout@v2 29 | 30 | # Initializes the CodeQL tools for scanning. 31 | - name: Initialize CodeQL 32 | uses: github/codeql-action/init@v1 33 | with: 34 | languages: ${{ matrix.language }} 35 | 36 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 37 | # If this step fails, then you should remove it and run the build manually (see below) 38 | - name: Autobuild 39 | uses: github/codeql-action/autobuild@v1 40 | 41 | - name: Perform CodeQL Analysis 42 | uses: github/codeql-action/analyze@v1 43 | -------------------------------------------------------------------------------- /webapp/src/index.js: -------------------------------------------------------------------------------- 1 | 2 | import {updateSearchTerms, updateSearchResultsTerms, updateRhsState, performSearch, openMeetingSettingsModal} from './actions'; 3 | 4 | import reducer from './reducer'; 5 | 6 | import ChannelSettingsModal from './components/meeting_settings'; 7 | 8 | import {id as pluginId} from './manifest'; 9 | export default class Plugin { 10 | initialize(registry, store) { 11 | registry.registerReducer(reducer); 12 | registry.registerWebSocketEventHandler( 13 | 'custom_' + pluginId + '_list', 14 | handleSearchHashtag(store), 15 | ); 16 | 17 | registry.registerRootComponent(ChannelSettingsModal); 18 | registry.registerChannelHeaderMenuAction('Agenda Settings', 19 | (channelId) => { 20 | store.dispatch(openMeetingSettingsModal(channelId)); 21 | }); 22 | } 23 | } 24 | 25 | function handleSearchHashtag(store) { 26 | return (msg) => { 27 | if (!msg.data) { 28 | return; 29 | } 30 | store.dispatch(updateSearchTerms(msg.data.hashtag)); 31 | store.dispatch(updateSearchResultsTerms(msg.data.hashtag)); 32 | 33 | store.dispatch(updateRhsState('search')); 34 | store.dispatch(performSearch(msg.data.hashtag)); 35 | }; 36 | } 37 | 38 | window.registerPlugin(pluginId, new Plugin()); 39 | -------------------------------------------------------------------------------- /webapp/babel.config.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. 2 | // See LICENSE.txt for license information. 3 | 4 | const config = { 5 | presets: [ 6 | ['@babel/preset-env', { 7 | targets: { 8 | chrome: 66, 9 | firefox: 60, 10 | edge: 42, 11 | safari: 12, 12 | }, 13 | modules: false, 14 | corejs: 3, 15 | debug: false, 16 | useBuiltIns: 'usage', 17 | shippedProposals: true, 18 | }], 19 | ['@babel/preset-react', { 20 | useBuiltIns: true, 21 | }], 22 | ['@babel/typescript', { 23 | allExtensions: true, 24 | isTSX: true, 25 | }], 26 | ['@emotion/babel-preset-css-prop'], 27 | ], 28 | plugins: [ 29 | '@babel/plugin-proposal-class-properties', 30 | '@babel/plugin-syntax-dynamic-import', 31 | '@babel/proposal-object-rest-spread', 32 | '@babel/plugin-proposal-optional-chaining', 33 | 'babel-plugin-typescript-to-proptypes', 34 | ], 35 | }; 36 | 37 | // Jest needs module transformation 38 | config.env = { 39 | test: { 40 | presets: config.presets, 41 | plugins: config.plugins, 42 | }, 43 | }; 44 | config.env.test.presets[0][1].modules = 'auto'; 45 | 46 | module.exports = config; 47 | -------------------------------------------------------------------------------- /webapp/src/client.js: -------------------------------------------------------------------------------- 1 | import {Client4} from 'mattermost-redux/client'; 2 | import {ClientError} from 'mattermost-redux/client/client4'; 3 | 4 | import {id as pluginId} from './manifest'; 5 | 6 | export default class Client { 7 | constructor() { 8 | this.url = `/plugins/${pluginId}/api/v1`; 9 | } 10 | 11 | getMeetingSettings = async (channelId) => { 12 | return this.doGet(`${this.url}/settings?channelId=${channelId}`); 13 | } 14 | 15 | saveMeetingSettings = async (meeting) => { 16 | return this.doPost(`${this.url}/settings`, meeting); 17 | } 18 | 19 | doGet = async (url, headers = {}) => { 20 | return this.doFetch(url, {headers}); 21 | } 22 | 23 | doPost = async (url, body, headers = {}) => { 24 | return this.doFetch(url, { 25 | method: 'POST', 26 | body: JSON.stringify(body), 27 | headers: { 28 | ...headers, 29 | 'Content-Type': 'application/json', 30 | }, 31 | }); 32 | } 33 | 34 | doFetch = async (url, {method = 'GET', body = null, headers = {}}) => { 35 | const options = Client4.getOptions({ 36 | method, 37 | body, 38 | headers: { 39 | ...headers, 40 | Accept: 'application/json', 41 | }, 42 | }); 43 | 44 | const response = await fetch(url, options); 45 | 46 | if (response.ok) { 47 | return response.json(); 48 | } 49 | 50 | const data = await response.text(); 51 | 52 | throw new ClientError(Client4.url, { 53 | message: data || '', 54 | status_code: response.status, 55 | url, 56 | }); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server/utils_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func Test_parseSchedule(t *testing.T) { 9 | type args struct { 10 | val string 11 | } 12 | tests := []struct { 13 | name string 14 | args args 15 | want time.Weekday 16 | wantErr bool 17 | }{ 18 | {name: "test number", args: args{val: "0"}, want: 0, wantErr: false}, 19 | {name: "test number", args: args{val: "14"}, want: -1, wantErr: true}, 20 | {name: "test short name", args: args{val: "Mon"}, want: 1, wantErr: false}, 21 | {name: "test short name lower case", args: args{val: "sat"}, want: 6, wantErr: false}, 22 | {name: "test short name upper case", args: args{val: "FRI"}, want: 5, wantErr: false}, 23 | {name: "test full name", args: args{val: "Monday"}, want: 1, wantErr: false}, 24 | {name: "test full name lower case", args: args{val: "saturday"}, want: 6, wantErr: false}, 25 | {name: "test full name upper case", args: args{val: "FRIDAY"}, want: 5, wantErr: false}, 26 | {name: "test unknown val", args: args{val: "SOMEDAY"}, want: -1, wantErr: true}, 27 | {name: "test number left-padded (one zero)", args: args{val: "01"}, want: 1, wantErr: false}, 28 | {name: "test number left-padded (three zeros)", args: args{val: "0001"}, want: 1, wantErr: false}, 29 | {name: "test invalid number left-padded (three zeros)", args: args{val: "0014"}, want: -1, wantErr: true}, 30 | } 31 | for _, tt := range tests { 32 | t.Run(tt.name, func(t *testing.T) { 33 | got, err := parseSchedule(tt.args.val) 34 | if (err != nil) != tt.wantErr { 35 | t.Errorf("parseSchedule() error = %v, wantErr %v", err, tt.wantErr) 36 | return 37 | } 38 | if got != tt.want { 39 | t.Errorf("parseSchedule() got = %v, want %v", got, tt.want) 40 | } 41 | }) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/plugin_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | "time" 11 | 12 | "github.com/mattermost/mattermost-server/v6/plugin/plugintest" 13 | "github.com/stretchr/testify/assert" 14 | ) 15 | 16 | func TestServeHTTP(t *testing.T) { 17 | assert := assert.New(t) 18 | plugin := Plugin{} 19 | api := &plugintest.API{} 20 | plugin.SetAPI(api) 21 | 22 | t.Run("get default meeting settings", func(t *testing.T) { 23 | // Mock get default meeting 24 | defaultMeeting := &Meeting{ 25 | ChannelID: "myChannelId", 26 | Schedule: []time.Weekday{time.Thursday}, 27 | HashtagFormat: "Jan02", 28 | } 29 | 30 | jsonMeeting, err := json.Marshal(defaultMeeting) 31 | assert.Nil(err) 32 | 33 | api.On("KVGet", "myChannelId").Return(jsonMeeting, nil) 34 | 35 | r := httptest.NewRequest(http.MethodGet, "/api/v1/settings?channelId=myChannelId", nil) 36 | r.Header.Add("Mattermost-User-Id", "theuserid") 37 | 38 | w := httptest.NewRecorder() 39 | plugin.ServeHTTP(nil, w, r) 40 | 41 | result := w.Result() 42 | assert.NotNil(result) 43 | bodyBytes, err := io.ReadAll(result.Body) 44 | assert.Nil(err) 45 | 46 | assert.Equal(string(jsonMeeting), string(bodyBytes)) 47 | }) 48 | 49 | t.Run("post meeting settings", func(t *testing.T) { 50 | // Mock set meeting 51 | meeting := &Meeting{ 52 | ChannelID: "myChannelId", 53 | Schedule: []time.Weekday{time.Tuesday}, 54 | HashtagFormat: "MyMeeting-Jan-02", 55 | } 56 | 57 | jsonMeeting, err := json.Marshal(meeting) 58 | assert.Nil(err) 59 | 60 | api.On("KVSet", "myChannelId", jsonMeeting).Return(nil) 61 | 62 | r := httptest.NewRequest(http.MethodPost, "/api/v1/settings", strings.NewReader(string(jsonMeeting))) 63 | r.Header.Add("Mattermost-User-Id", "theuserid") 64 | 65 | w := httptest.NewRecorder() 66 | plugin.ServeHTTP(nil, w, r) 67 | 68 | result := w.Result() 69 | assert.NotNil(result) 70 | assert.Equal(http.StatusOK, result.StatusCode) 71 | }) 72 | } 73 | -------------------------------------------------------------------------------- /webapp/src/actions/index.js: -------------------------------------------------------------------------------- 1 | import {searchPostsWithParams} from 'mattermost-redux/actions/search'; 2 | 3 | import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams'; 4 | import {getConfig} from 'mattermost-redux/selectors/entities/general'; 5 | 6 | import Client from '../client'; 7 | 8 | import ActionTypes from '../action_types'; 9 | 10 | export function fetchMeetingSettings(channelId = '') { 11 | return async (dispatch) => { 12 | let data; 13 | try { 14 | data = await (new Client()).getMeetingSettings(channelId); 15 | } catch (error) { 16 | return {error}; 17 | } 18 | 19 | dispatch({ 20 | type: ActionTypes.RECEIVED_MEETING_SETTINGS, 21 | data, 22 | }); 23 | 24 | return {data}; 25 | }; 26 | } 27 | 28 | export function saveMeetingSettings(meeting) { 29 | let data; 30 | try { 31 | data = (new Client()).saveMeetingSettings(meeting); 32 | } catch (error) { 33 | return {error}; 34 | } 35 | 36 | return {data}; 37 | } 38 | 39 | export const openMeetingSettingsModal = (channelId = '') => (dispatch) => { 40 | dispatch({ 41 | type: ActionTypes.OPEN_MEETING_SETTINGS_MODAL, 42 | channelId, 43 | }); 44 | }; 45 | 46 | export const closeMeetingSettingsModal = () => (dispatch) => { 47 | dispatch({ 48 | type: ActionTypes.CLOSE_MEETING_SETTINGS_MODAL, 49 | }); 50 | }; 51 | 52 | // Hackathon hack: "Copying" these actions below directly from webapp 53 | 54 | export function updateSearchTerms(terms) { 55 | return { 56 | type: 'UPDATE_RHS_SEARCH_TERMS', 57 | terms, 58 | }; 59 | } 60 | 61 | export function updateSearchResultsTerms(terms) { 62 | return { 63 | type: 'UPDATE_RHS_SEARCH_RESULTS_TERMS', 64 | terms, 65 | }; 66 | } 67 | 68 | export function updateRhsState(rhsState) { 69 | return { 70 | type: 'UPDATE_RHS_STATE', 71 | state: rhsState, 72 | }; 73 | } 74 | 75 | export function performSearch(terms) { 76 | return (dispatch, getState) => { 77 | const teamId = getCurrentTeamId(getState()); 78 | const config = getConfig(getState()); 79 | const viewArchivedChannels = config.ExperimentalViewArchivedChannels === 'true'; 80 | 81 | return dispatch(searchPostsWithParams(teamId, {terms, is_or_search: false, include_deleted_channels: viewArchivedChannels, page: 0, per_page: 20}, true)); 82 | }; 83 | } -------------------------------------------------------------------------------- /server/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | "time" 7 | 8 | "github.com/pkg/errors" 9 | ) 10 | 11 | const ( 12 | scheduleErrorInvalid = "invalid weekday. Must be between 1-5 or Mon-Fri" 13 | scheduleErrorInvalidNumber = "invalid weekday. Must be between 1-5" 14 | ) 15 | 16 | var daysOfWeek = map[string]time.Weekday{} 17 | 18 | func init() { 19 | for d := time.Sunday; d <= time.Saturday; d++ { 20 | name := strings.ToLower(d.String()) 21 | daysOfWeek[name] = d 22 | daysOfWeek[name[:3]] = d 23 | } 24 | } 25 | 26 | // parseSchedule will return a given Weekday based on the string being either a number, 27 | // short / full name day of week. 28 | func parseSchedule(val string) (time.Weekday, error) { 29 | if len(val) < 3 { 30 | return parseScheduleNumber(val) 31 | } 32 | if weekDayName, ok := daysOfWeek[strings.ToLower(val)]; ok { 33 | return weekDayName, nil 34 | } 35 | // try parsing number again in case prefixed by zeros 36 | weekDayInt, err := parseScheduleNumber(val) 37 | if err != nil { 38 | return -1, errors.New(scheduleErrorInvalid) 39 | } 40 | return weekDayInt, nil 41 | } 42 | 43 | // parseScheduleNumber will return a given Weekday based on the corresponding int val. 44 | func parseScheduleNumber(val string) (time.Weekday, error) { 45 | weekdayInt, err := strconv.Atoi(val) 46 | validWeekday := weekdayInt >= 0 && weekdayInt <= 6 47 | if err != nil || !validWeekday { 48 | return -1, errors.New(scheduleErrorInvalidNumber) 49 | } 50 | return time.Weekday(weekdayInt), nil 51 | } 52 | 53 | // nextWeekdayDate calculates the date of the next weekday from the given 54 | // list of days from today's date. 55 | // If nextWeek is true, it will be based on the next calendar week. 56 | func nextWeekdayDateInWeek(meetingDays []time.Weekday, nextWeek bool) (*time.Time, error) { 57 | if len(meetingDays) == 0 { 58 | return nil, errors.New("missing weekdays to calculate date") 59 | } 60 | 61 | todayWeekday := time.Now().Weekday() 62 | 63 | // Find which meeting weekday to calculate the date for 64 | meetingDay := meetingDays[0] 65 | for _, day := range meetingDays { 66 | if todayWeekday <= day { 67 | meetingDay = day 68 | break 69 | } 70 | } 71 | 72 | return nextWeekdayDate(meetingDay, nextWeek) 73 | } 74 | 75 | // nextWeekdayDate calculates the date of the next given weekday 76 | // from today's date. 77 | // If nextWeek is true, it will be based on the next calendar week. 78 | func nextWeekdayDate(meetingDay time.Weekday, nextWeek bool) (*time.Time, error) { 79 | daysTill := daysTillNextWeekday(time.Now().Weekday(), meetingDay, nextWeek) 80 | nextDate := time.Now().AddDate(0, 0, daysTill) 81 | 82 | return &nextDate, nil 83 | } 84 | 85 | // daysTillNextWeekday calculates the amount of days between two weekdays. 86 | // If nexWeek is true, the nextDay will be based on the next calendar week. 87 | func daysTillNextWeekday(today time.Weekday, nextDay time.Weekday, nextWeek bool) int { 88 | if today > nextDay { 89 | return int((7 - today) + nextDay) 90 | } 91 | 92 | daysTillNextWeekday := int(nextDay - today) 93 | 94 | if nextWeek { 95 | daysTillNextWeekday += 7 96 | } 97 | 98 | return daysTillNextWeekday 99 | } 100 | -------------------------------------------------------------------------------- /webapp/webpack.config.js: -------------------------------------------------------------------------------- 1 | const exec = require('child_process').exec; 2 | 3 | const path = require('path'); 4 | 5 | const PLUGIN_ID = require('../plugin.json').id; 6 | 7 | const NPM_TARGET = process.env.npm_lifecycle_event; //eslint-disable-line no-process-env 8 | let mode = 'production'; 9 | let devtool = ''; 10 | if (NPM_TARGET === 'debug' || NPM_TARGET === 'debug:watch') { 11 | mode = 'development'; 12 | devtool = 'source-map'; 13 | } 14 | 15 | const plugins = []; 16 | if (NPM_TARGET === 'build:watch' || NPM_TARGET === 'debug:watch') { 17 | plugins.push({ 18 | apply: (compiler) => { 19 | compiler.hooks.watchRun.tap('WatchStartPlugin', () => { 20 | // eslint-disable-next-line no-console 21 | console.log('Change detected. Rebuilding webapp.'); 22 | }); 23 | compiler.hooks.afterEmit.tap('AfterEmitPlugin', () => { 24 | exec('cd .. && make deploy-from-watch', (err, stdout, stderr) => { 25 | if (stdout) { 26 | process.stdout.write(stdout); 27 | } 28 | if (stderr) { 29 | process.stderr.write(stderr); 30 | } 31 | }); 32 | }); 33 | }, 34 | }); 35 | } 36 | 37 | module.exports = { 38 | entry: [ 39 | './src/index.js', 40 | ], 41 | resolve: { 42 | modules: [ 43 | 'src', 44 | 'node_modules', 45 | path.resolve(__dirname), 46 | ], 47 | extensions: ['*', '.js', '.jsx', '.ts', '.tsx'], 48 | }, 49 | module: { 50 | rules: [ 51 | { 52 | test: /\.(js|jsx|ts|tsx)$/, 53 | exclude: /node_modules/, 54 | use: { 55 | loader: 'babel-loader', 56 | options: { 57 | cacheDirectory: true, 58 | 59 | // Babel configuration is in babel.config.js because jest requires it to be there. 60 | }, 61 | }, 62 | }, 63 | { 64 | test: /\.scss$/, 65 | use: [ 66 | 'style-loader', 67 | { 68 | loader: 'css-loader', 69 | }, 70 | { 71 | loader: 'sass-loader', 72 | options: { 73 | sassOptions: { 74 | includePaths: ['node_modules/compass-mixins/lib', 'sass'], 75 | }, 76 | }, 77 | }, 78 | ], 79 | }, 80 | ], 81 | }, 82 | externals: { 83 | react: 'React', 84 | 'react-dom': 'ReactDOM', 85 | redux: 'Redux', 86 | 'react-redux': 'ReactRedux', 87 | 'prop-types': 'PropTypes', 88 | 'react-bootstrap': 'ReactBootstrap', 89 | 'react-router-dom': 'ReactRouterDom', 90 | }, 91 | output: { 92 | devtoolNamespace: PLUGIN_ID, 93 | path: path.join(__dirname, '/dist'), 94 | publicPath: '/', 95 | filename: 'main.js', 96 | }, 97 | devtool, 98 | mode, 99 | plugins, 100 | }; 101 | -------------------------------------------------------------------------------- /server/configuration.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | 6 | "github.com/pkg/errors" 7 | ) 8 | 9 | // configuration captures the plugin's external configuration as exposed in the Mattermost server 10 | // configuration, as well as values computed from the configuration. Any public fields will be 11 | // deserialized from the Mattermost server configuration in OnConfigurationChange. 12 | // 13 | // As plugins are inherently concurrent (hooks being called asynchronously), and the plugin 14 | // configuration can change at any time, access to the configuration must be synchronized. The 15 | // strategy used in this plugin is to guard a pointer to the configuration, and clone the entire 16 | // struct whenever it changes. You may replace this with whatever strategy you choose. 17 | // 18 | // If you add non-reference types to your configuration struct, be sure to rewrite Clone as a deep 19 | // copy appropriate for your types. 20 | type configuration struct { 21 | } 22 | 23 | // Clone shallow copies the configuration. Your implementation may require a deep copy if 24 | // your configuration has reference types. 25 | func (c *configuration) Clone() *configuration { 26 | var clone = *c 27 | return &clone 28 | } 29 | 30 | // getConfiguration retrieves the active configuration under lock, making it safe to use 31 | // concurrently. The active configuration may change underneath the client of this method, but 32 | // the struct returned by this API call is considered immutable. 33 | func (p *Plugin) getConfiguration() *configuration { 34 | p.configurationLock.RLock() 35 | defer p.configurationLock.RUnlock() 36 | 37 | if p.configuration == nil { 38 | return &configuration{} 39 | } 40 | 41 | return p.configuration 42 | } 43 | 44 | // setConfiguration replaces the active configuration under lock. 45 | // 46 | // Do not call setConfiguration while holding the configurationLock, as sync.Mutex is not 47 | // reentrant. In particular, avoid using the plugin API entirely, as this may in turn trigger a 48 | // hook back into the plugin. If that hook attempts to acquire this lock, a deadlock may occur. 49 | // 50 | // This method panics if setConfiguration is called with the existing configuration. This almost 51 | // certainly means that the configuration was modified without being cloned and may result in 52 | // an unsafe access. 53 | func (p *Plugin) setConfiguration(configuration *configuration) { 54 | p.configurationLock.Lock() 55 | defer p.configurationLock.Unlock() 56 | 57 | if configuration != nil && p.configuration == configuration { 58 | // Ignore assignment if the configuration struct is empty. Go will optimize the 59 | // allocation for same to point at the same memory address, breaking the check 60 | // above. 61 | if reflect.ValueOf(*configuration).NumField() == 0 { 62 | return 63 | } 64 | 65 | panic("setConfiguration called with the existing configuration") 66 | } 67 | 68 | p.configuration = configuration 69 | } 70 | 71 | // OnConfigurationChange is invoked when configuration changes may have been made. 72 | func (p *Plugin) OnConfigurationChange() error { 73 | var configuration = new(configuration) 74 | 75 | // Load the public configuration fields from the Mattermost server configuration. 76 | if err := p.API.LoadPluginConfiguration(configuration); err != nil { 77 | return errors.Wrap(err, "failed to load plugin configuration") 78 | } 79 | 80 | p.setConfiguration(configuration) 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mattermost/mattermost-plugin-agenda 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/mattermost/mattermost-plugin-api v0.0.27 7 | github.com/mattermost/mattermost-server/v6 v6.7.2 8 | github.com/pkg/errors v0.9.1 9 | github.com/stretchr/testify v1.8.0 10 | ) 11 | 12 | require ( 13 | github.com/blang/semver v3.5.1+incompatible // indirect 14 | github.com/blang/semver/v4 v4.0.0 // indirect 15 | github.com/davecgh/go-spew v1.1.1 // indirect 16 | github.com/dustin/go-humanize v1.0.0 // indirect 17 | github.com/dyatlov/go-opengraph v0.0.0-20210112100619-dae8665a5b09 // indirect 18 | github.com/fatih/color v1.13.0 // indirect 19 | github.com/francoispqt/gojay v1.2.13 // indirect 20 | github.com/go-asn1-ber/asn1-ber v1.5.4 // indirect 21 | github.com/go-sql-driver/mysql v1.6.0 // indirect 22 | github.com/golang/protobuf v1.5.2 // indirect 23 | github.com/google/uuid v1.3.0 // indirect 24 | github.com/gorilla/websocket v1.5.0 // indirect 25 | github.com/graph-gophers/graphql-go v1.4.0 // indirect 26 | github.com/hashicorp/go-hclog v1.2.1 // indirect 27 | github.com/hashicorp/go-plugin v1.4.4 // indirect 28 | github.com/hashicorp/yamux v0.0.0-20211028200310-0bc27b27de87 // indirect 29 | github.com/json-iterator/go v1.1.12 // indirect 30 | github.com/klauspost/compress v1.15.6 // indirect 31 | github.com/klauspost/cpuid/v2 v2.0.13 // indirect 32 | github.com/lib/pq v1.10.6 // indirect 33 | github.com/mattermost/go-i18n v1.11.1-0.20211013152124-5c415071e404 // indirect 34 | github.com/mattermost/ldap v0.0.0-20201202150706-ee0e6284187d // indirect 35 | github.com/mattermost/logr/v2 v2.0.15 // indirect 36 | github.com/mattn/go-colorable v0.1.12 // indirect 37 | github.com/mattn/go-isatty v0.0.14 // indirect 38 | github.com/minio/md5-simd v1.1.2 // indirect 39 | github.com/minio/minio-go/v7 v7.0.28 // indirect 40 | github.com/minio/sha256-simd v1.0.0 // indirect 41 | github.com/mitchellh/go-homedir v1.1.0 // indirect 42 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 43 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 44 | github.com/modern-go/reflect2 v1.0.2 // indirect 45 | github.com/oklog/run v1.1.0 // indirect 46 | github.com/pborman/uuid v1.2.1 // indirect 47 | github.com/pelletier/go-toml v1.9.5 // indirect 48 | github.com/philhofer/fwd v1.1.1 // indirect 49 | github.com/pmezard/go-difflib v1.0.0 // indirect 50 | github.com/rs/xid v1.4.0 // indirect 51 | github.com/sirupsen/logrus v1.8.1 // indirect 52 | github.com/stretchr/objx v0.4.0 // indirect 53 | github.com/tinylib/msgp v1.1.6 // indirect 54 | github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect 55 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 56 | github.com/wiggin77/merror v1.0.3 // indirect 57 | github.com/wiggin77/srslog v1.0.1 // indirect 58 | github.com/yuin/goldmark v1.4.12 // indirect 59 | golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e // indirect 60 | golang.org/x/net v0.0.0-20220614195744-fb05da6f9022 // indirect 61 | golang.org/x/sys v0.0.0-20220614162138-6c1b26c55098 // indirect 62 | golang.org/x/text v0.3.7 // indirect 63 | google.golang.org/genproto v0.0.0-20220614165028-45ed7f3ff16e // indirect 64 | google.golang.org/grpc v1.47.0 // indirect 65 | google.golang.org/protobuf v1.28.0 // indirect 66 | gopkg.in/ini.v1 v1.66.6 // indirect 67 | gopkg.in/natefinch/lumberjack.v2 v2.0.0 // indirect 68 | gopkg.in/yaml.v2 v2.4.0 // indirect 69 | gopkg.in/yaml.v3 v3.0.1 // indirect 70 | ) 71 | -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "webpack --mode=production", 5 | "build:watch": "webpack --mode=production --watch", 6 | "debug": "webpack --mode=none", 7 | "debug:watch": "webpack --mode=development --watch", 8 | "lint": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx --ext tsx --ext ts . --quiet --cache", 9 | "fix": "eslint --ignore-pattern node_modules --ignore-pattern dist --ext .js --ext .jsx --ext tsx --ext ts . --quiet --fix --cache", 10 | "test": "jest --forceExit --detectOpenHandles --verbose", 11 | "test:watch": "jest --watch", 12 | "test-ci": "jest --forceExit --detectOpenHandles --maxWorkers=2", 13 | "check-types": "tsc" 14 | }, 15 | "devDependencies": { 16 | "@babel/cli": "7.11.6", 17 | "@babel/core": "7.11.6", 18 | "@babel/plugin-proposal-class-properties": "7.10.4", 19 | "@babel/plugin-proposal-object-rest-spread": "7.11.0", 20 | "@babel/plugin-proposal-optional-chaining": "7.11.0", 21 | "@babel/plugin-syntax-dynamic-import": "7.8.3", 22 | "@babel/polyfill": "7.11.5", 23 | "@babel/preset-env": "7.11.5", 24 | "@babel/preset-react": "7.10.4", 25 | "@babel/preset-typescript": "7.10.4", 26 | "@babel/runtime": "7.11.2", 27 | "@emotion/babel-preset-css-prop": "10.0.27", 28 | "@emotion/core": "10.0.35", 29 | "@types/enzyme": "3.10.6", 30 | "@types/jest": "26.0.14", 31 | "@types/node": "14.11.2", 32 | "@types/react": "16.9.49", 33 | "@types/react-dom": "16.9.8", 34 | "@types/react-intl": "3.0.0", 35 | "@types/react-redux": "7.1.9", 36 | "@types/react-router-dom": "5.1.5", 37 | "@types/react-transition-group": "4.4.0", 38 | "@typescript-eslint/eslint-plugin": "4.2.0", 39 | "@typescript-eslint/parser": "4.2.0", 40 | "babel-eslint": "10.1.0", 41 | "babel-jest": "26.3.0", 42 | "babel-loader": "8.1.0", 43 | "babel-plugin-typescript-to-proptypes": "1.4.1", 44 | "css-loader": "4.3.0", 45 | "enzyme": "3.11.0", 46 | "enzyme-adapter-react-16": "1.15.4", 47 | "enzyme-to-json": "3.5.0", 48 | "eslint": "7.9.0", 49 | "eslint-import-resolver-webpack": "0.12.2", 50 | "eslint-plugin-import": "2.22.0", 51 | "eslint-plugin-react": "7.20.6", 52 | "eslint-plugin-react-hooks": "4.1.2", 53 | "file-loader": "6.1.0", 54 | "identity-obj-proxy": "3.0.0", 55 | "jest": "26.4.2", 56 | "jest-canvas-mock": "2.2.0", 57 | "jest-junit": "11.1.0", 58 | "mattermost-webapp": "github:mattermost/mattermost-webapp#23f5f93d9f12a7e2b5623e5cee6814366abd9a0f", 59 | "minimist": "1.2.5", 60 | "sass-loader": "10.0.2", 61 | "serialize-javascript": "5.0.1", 62 | "style-loader": "1.2.1", 63 | "webpack": "4.44.2", 64 | "webpack-cli": "3.3.12" 65 | }, 66 | "dependencies": { 67 | "core-js": "3.6.5", 68 | "mattermost-redux": "5.27.0", 69 | "react": "16.13.1", 70 | "react-redux": "7.2.1", 71 | "redux": "4.0.5", 72 | "typescript": "4.0.3" 73 | }, 74 | "jest": { 75 | "snapshotSerializers": [ 76 | "/node_modules/enzyme-to-json/serializer" 77 | ], 78 | "testPathIgnorePatterns": [ 79 | "/node_modules/", 80 | "/non_npm_dependencies/" 81 | ], 82 | "clearMocks": true, 83 | "collectCoverageFrom": [ 84 | "src/**/*.{js,jsx}" 85 | ], 86 | "coverageReporters": [ 87 | "lcov", 88 | "text-summary" 89 | ], 90 | "moduleNameMapper": { 91 | "^.+\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "identity-obj-proxy", 92 | "^.+\\.(css|less|scss)$": "identity-obj-proxy", 93 | "^.*i18n.*\\.(json)$": "/tests/i18n_mock.json", 94 | "^bundle-loader\\?lazy\\!(.*)$": "$1" 95 | }, 96 | "moduleDirectories": [ 97 | "", 98 | "node_modules", 99 | "non_npm_dependencies" 100 | ], 101 | "reporters": [ 102 | "default", 103 | "jest-junit" 104 | ], 105 | "transformIgnorePatterns": [ 106 | "node_modules/(?!react-native|react-router|mattermost-webapp)" 107 | ], 108 | "setupFiles": [ 109 | "jest-canvas-mock" 110 | ], 111 | "setupFilesAfterEnv": [ 112 | "/tests/setup.js" 113 | ], 114 | "testURL": "http://localhost:8065" 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /server/meeting.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "regexp" 7 | "sort" 8 | "strings" 9 | "time" 10 | 11 | "github.com/mattermost/mattermost-server/v6/model" 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | var ( 16 | meetingDateFormatRegex = regexp.MustCompile(`(?m)^(?P.*)?(?:{{\s*(?P.*)\s*}})(?P.*)?$`) 17 | ) 18 | 19 | // Meeting represents a meeting agenda 20 | type Meeting struct { 21 | ChannelID string `json:"channelId"` 22 | Schedule []time.Weekday `json:"schedule"` 23 | HashtagFormat string `json:"hashtagFormat"` // Default: {ChannelName}-Jan02 24 | } 25 | 26 | // GetMeeting returns a meeting 27 | func (p *Plugin) GetMeeting(channelID string) (*Meeting, error) { 28 | meetingBytes, appErr := p.API.KVGet(channelID) 29 | if appErr != nil { 30 | return nil, appErr 31 | } 32 | 33 | var meeting *Meeting 34 | if meetingBytes != nil { 35 | if err := json.Unmarshal(meetingBytes, &meeting); err != nil { 36 | return nil, err 37 | } 38 | } else { 39 | // Return a default value 40 | channel, err := p.API.GetChannel(channelID) 41 | if err != nil { 42 | return nil, err 43 | } 44 | meeting = &Meeting{ 45 | Schedule: []time.Weekday{time.Thursday}, 46 | HashtagFormat: strings.Join([]string{fmt.Sprintf("%.15s", channel.Name), "{{ Jan02 }}"}, "-"), 47 | ChannelID: channelID, 48 | } 49 | } 50 | 51 | return meeting, nil 52 | } 53 | 54 | // SaveMeeting saves a meeting 55 | func (p *Plugin) SaveMeeting(meeting *Meeting) error { 56 | jsonMeeting, err := json.Marshal(meeting) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | if appErr := p.API.KVSet(meeting.ChannelID, jsonMeeting); appErr != nil { 62 | return appErr 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func (p *Plugin) calculateQueueItemNumberAndUpdateOldItems(meeting *Meeting, args *model.CommandArgs, hashtag string) (int, error) { 69 | c, appErr := p.API.GetChannel(args.ChannelId) 70 | if appErr != nil { 71 | return 0, appErr 72 | } 73 | terms := fmt.Sprintf("in:%s %s", c.Name, hashtag) 74 | searchResults, appErr := p.API.SearchPostsInTeamForUser(args.TeamId, args.UserId, model.SearchParameter{Terms: &terms}) 75 | if appErr != nil { 76 | return 0, errors.Wrap(appErr, "Error searching posts to find hashtags") 77 | } 78 | 79 | counter := 1 80 | 81 | var sortedPosts []*model.Post 82 | // TODO we won't need to do this once we fix https://github.com/mattermost/mattermost-server/issues/11006 83 | for _, post := range searchResults.PostList.Posts { 84 | sortedPosts = append(sortedPosts, post) 85 | } 86 | 87 | sort.Slice(sortedPosts, func(i, j int) bool { 88 | return sortedPosts[i].CreateAt < sortedPosts[j].CreateAt 89 | }) 90 | 91 | for _, post := range sortedPosts { 92 | _, parsedMessage, err := parseMeetingPost(meeting, post) 93 | if err != nil { 94 | p.API.LogDebug(err.Error()) 95 | return 0, errors.New(err.Error()) 96 | } 97 | 98 | _, updateErr := p.API.UpdatePost(&model.Post{ 99 | Id: post.Id, 100 | UserId: post.UserId, 101 | ChannelId: post.ChannelId, 102 | RootId: post.RootId, 103 | Message: fmt.Sprintf("#### %v %v) %v", hashtag, counter, parsedMessage.textMessage), 104 | }) 105 | if updateErr != nil { 106 | return 0, errors.Wrap(updateErr, "Error updating post") 107 | } 108 | 109 | counter++ 110 | } 111 | 112 | return counter, nil 113 | } 114 | 115 | // GenerateHashtag returns a meeting hashtag 116 | func (p *Plugin) GenerateHashtag(channelID string, nextWeek bool, weekday int) (string, error) { 117 | meeting, err := p.GetMeeting(channelID) 118 | if err != nil { 119 | return "", err 120 | } 121 | 122 | var meetingDate *time.Time 123 | if weekday > -1 { 124 | // Get date for given day 125 | if meetingDate, err = nextWeekdayDate(time.Weekday(weekday), nextWeek); err != nil { 126 | return "", err 127 | } 128 | } else { 129 | // Get date for the list of days of the week 130 | if meetingDate, err = nextWeekdayDateInWeek(meeting.Schedule, nextWeek); err != nil { 131 | return "", err 132 | } 133 | } 134 | 135 | var hashtag string 136 | 137 | if matchGroups := meetingDateFormatRegex.FindStringSubmatch(meeting.HashtagFormat); len(matchGroups) == 4 { 138 | var ( 139 | prefix string 140 | hashtagFormat string 141 | postfix string 142 | ) 143 | prefix = matchGroups[1] 144 | hashtagFormat = strings.TrimSpace(matchGroups[2]) 145 | postfix = matchGroups[3] 146 | 147 | hashtag = fmt.Sprintf("#%s%v%s", prefix, meetingDate.Format(hashtagFormat), postfix) 148 | } else { 149 | hashtag = fmt.Sprintf("#%s", meeting.HashtagFormat) 150 | } 151 | 152 | return hashtag, nil 153 | } 154 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Disclaimer 2 | 3 | **This repository is community supported and not maintained by Mattermost. Mattermost disclaims liability for integrations, including Third Party Integrations and Mattermost Integrations. Integrations may be modified or discontinued at any time.** 4 | 5 | # Agenda Plugin 6 | 7 | [![CircleCI](https://img.shields.io/circleci/project/github/mattermost/mattermost-plugin-agenda/master.svg)](https://circleci.com/gh/mattermost/mattermost-plugin-agenda) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/mattermost/mattermost-plugin-agenda)](https://goreportcard.com/report/github.com/mattermost/mattermost-plugin-agenda) 9 | [![Code Coverage](https://img.shields.io/codecov/c/github/mattermost/mattermost-plugin-agenda/master.svg)](https://codecov.io/gh/mattermost/mattermost-plugin-agenda) 10 | [![Release](https://img.shields.io/github/v/release/mattermost/mattermost-plugin-agenda?include_prereleases)](https://github.com/mattermost/mattermost-plugin-agenda/releases/latest) 11 | [![HW](https://img.shields.io/github/issues/mattermost/mattermost-plugin-agenda/Up%20For%20Grabs?color=dark%20green&label=Help%20Wanted)](https://github.com/mattermost/mattermost-plugin-agenda/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3A%22Up+For+Grabs%22+label%3A%22Help+Wanted%22) 12 | 13 | **Maintainer:** [@mickmister](https://github.com/mickmister) 14 | 15 | The Agenda Plugin helps users queue and list items in a channel's meeting agenda. The agenda is identified by a hashtag based on the meeting date. 16 | 17 | The plugin will create posts for the user preceding the agenda item with configured hashtag format and can open a search with that hashtag to view the agenda list. 18 | 19 | Initial development as part of [Mattermost Hackathon 2019](https://github.com/mattermost/mattermost-hackathon-nov2019) which was demoed [here](https://www.youtube.com/watch?v=Tl08dt7TheI&feature=youtu.be&t=821). 20 | 21 | ## Usage 22 | 23 | ### Enable the plugin 24 | 25 | Once this plugin is installed, a Mattermost admin can enable it in the Mattermost System Console by going to **Plugins > Plugin Management**, and selecting **Enable**. 26 | 27 | ### Configure meeting settings 28 | 29 | The meeting settings for each channel can be configured in the Channel Header Dropdown. 30 | 31 | ![channel_header_menu](./assets/channelHeaderDropdown.png) 32 | 33 | ![settings_dialog](./assets/settingsDialog.png) 34 | 35 | Meeting settings include: 36 | 37 | - Schedule Day: Day of the week when the meeting is scheduled. 38 | - Hashtag Format: The format of the hashtag for the meeting date. The date format is based on [Go date and time formatting](https://yourbasic.org/golang/format-parse-string-time-date-example/#standard-time-and-date-formats). 39 | The date format must be wrapped in double Braces ( {{ }} ). 40 | A default is generated from the first 15 characters of the channel's name with the short name of the month and day (i.e. Dev-{{ Jan02 }}). 41 | 42 | #### Slash Commands to manage the meeting agenda 43 | 44 | ``` 45 | /agenda queue [meetingDay] message 46 | ``` 47 | Creates a post for the user with the given `message` for the next meeting date or the specified `meetingDay` (optional). The configured hashtag will precede the `message`. 48 | The meeting day supports long (Monday, Tuesday), short name (Mon Tue), number (0-6) or `next-week`. If `next-week` is indicated, it will use the date of the first meeting in the next calendar week. 49 | 50 | ![post_example](./assets/postExample.png) 51 | 52 | ``` 53 | /agenda list [meetingDay] 54 | ``` 55 | Executes a search of the hashtag of the next meeting or the specified `meetingDay` (optional), opening the RHS with all the posts with that hashtag. 56 | The meeting day supports long (Monday, Tuesday), short name (Mon Tue), number (0-6) or `next-week`. If `next-week` is indicated, it will use the date of the first meeting in the next calendar week. 57 | 58 | ``` 59 | /agenda setting field value 60 | ``` 61 | Updates the given setting with the provided value for the meeting settings of that channel. 62 | 63 | `Field` can be one of: 64 | 65 | - `schedule`: Day of the week of the meeting. It is an int based on [`time.Weekday`](https://golang.org/pkg/time/#Weekday) 66 | - `hashtag`: Format of the hashtag for the meeting date. It is based on the format used in [`time.Format`](https://golang.org/pkg/time/#Time.Format) 67 | 68 | ## Future Improvements 69 | 70 | - Mark items as resolved or queue for next week. 71 | - Queue a post using a menu option in the post dot menu. 72 | - Handle time in meeting schedule. 73 | 74 | ## Contributing 75 | 76 | If you would like to make contributions to this plugin, please checkout the open issues labeled [`Help Wanted` and `Up For Grabs`](https://github.com/mattermost/mattermost-plugin-agenda/issues?q=is%3Aopen+label%3A%22Up+For+Grabs%22+label%3A%22Help+Wanted%22) 77 | -------------------------------------------------------------------------------- /server/plugin.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "sync" 9 | 10 | "github.com/pkg/errors" 11 | 12 | pluginapi "github.com/mattermost/mattermost-plugin-api" 13 | "github.com/mattermost/mattermost-server/v6/model" 14 | "github.com/mattermost/mattermost-server/v6/plugin" 15 | 16 | root "github.com/mattermost/mattermost-plugin-agenda" 17 | ) 18 | 19 | // Plugin implements the interface expected by the Mattermost server to communicate between the server and plugin processes. 20 | type Plugin struct { 21 | plugin.MattermostPlugin 22 | 23 | // configurationLock synchronizes access to the configuration. 24 | configurationLock sync.RWMutex 25 | 26 | // configuration is the active plugin configuration. Consult getConfiguration and 27 | // setConfiguration for usage. 28 | configuration *configuration 29 | 30 | // BotId of the created bot account. 31 | botID string 32 | } 33 | 34 | var ( 35 | Manifest model.Manifest = root.Manifest 36 | ) 37 | 38 | // ServeHTTP demonstrates a plugin that handles HTTP requests by greeting the world. 39 | func (p *Plugin) ServeHTTP(c *plugin.Context, w http.ResponseWriter, r *http.Request) { 40 | w.Header().Set("Content-Type", "application/json") 41 | 42 | switch path := r.URL.Path; path { 43 | case "/api/v1/settings": 44 | p.httpMeetingSettings(w, r) 45 | case "/api/v1/meeting-days-autocomplete": 46 | p.httpMeetingDaysAutocomplete(w, r, false) 47 | case "/api/v1/list-meeting-days-autocomplete": 48 | p.httpMeetingDaysAutocomplete(w, r, true) 49 | default: 50 | http.NotFound(w, r) 51 | } 52 | } 53 | 54 | // OnActivate is invoked when the plugin is activated 55 | func (p *Plugin) OnActivate() error { 56 | if err := p.registerCommands(); err != nil { 57 | return errors.Wrap(err, "failed to register commands") 58 | } 59 | 60 | client := pluginapi.NewClient(p.API, p.Driver) 61 | botID, err := client.Bot.EnsureBot(&model.Bot{ 62 | Username: "agenda", 63 | DisplayName: "Agenda Plugin Bot", 64 | Description: "Created by the Agenda plugin.", 65 | }) 66 | if err != nil { 67 | return errors.Wrap(err, "failed to ensure agenda bot") 68 | } 69 | p.botID = botID 70 | 71 | return nil 72 | } 73 | 74 | func (p *Plugin) httpMeetingSettings(w http.ResponseWriter, r *http.Request) { 75 | mattermostUserID := r.Header.Get("Mattermost-User-Id") 76 | if mattermostUserID == "" { 77 | http.Error(w, "Not Authorized", http.StatusUnauthorized) 78 | } 79 | 80 | switch r.Method { 81 | case http.MethodPost: 82 | p.httpMeetingSaveSettings(w, r, mattermostUserID) 83 | case http.MethodGet: 84 | p.httpMeetingGetSettings(w, r, mattermostUserID) 85 | default: 86 | http.Error(w, "Request: "+r.Method+" is not allowed.", http.StatusMethodNotAllowed) 87 | } 88 | } 89 | 90 | func (p *Plugin) httpMeetingSaveSettings(w http.ResponseWriter, r *http.Request, mmUserID string) { 91 | body, err := io.ReadAll(r.Body) 92 | if err != nil { 93 | http.Error(w, err.Error(), http.StatusBadRequest) 94 | return 95 | } 96 | 97 | var meeting *Meeting 98 | if err = json.Unmarshal(body, &meeting); err != nil { 99 | http.Error(w, err.Error(), http.StatusInternalServerError) 100 | return 101 | } 102 | 103 | if err = p.SaveMeeting(meeting); err != nil { 104 | http.Error(w, err.Error(), http.StatusInternalServerError) 105 | return 106 | } 107 | 108 | resp := struct { 109 | Status string 110 | }{"OK"} 111 | 112 | p.writeJSON(w, resp) 113 | } 114 | 115 | func (p *Plugin) httpMeetingGetSettings(w http.ResponseWriter, r *http.Request, mmUserID string) { 116 | channelID, ok := r.URL.Query()["channelId"] 117 | 118 | if !ok || len(channelID[0]) < 1 { 119 | http.Error(w, "Missing channelId parameter", http.StatusBadRequest) 120 | return 121 | } 122 | 123 | meeting, err := p.GetMeeting(channelID[0]) 124 | if err != nil { 125 | http.Error(w, err.Error(), http.StatusBadRequest) 126 | return 127 | } 128 | 129 | p.writeJSON(w, meeting) 130 | } 131 | 132 | func (p *Plugin) writeJSON(w http.ResponseWriter, v interface{}) { 133 | b, err := json.Marshal(v) 134 | if err != nil { 135 | p.API.LogWarn("Failed to marshal JSON response", "error", err.Error()) 136 | w.WriteHeader(http.StatusInternalServerError) 137 | return 138 | } 139 | _, err = w.Write(b) 140 | if err != nil { 141 | p.API.LogWarn("Failed to write JSON response", "error", err.Error()) 142 | w.WriteHeader(http.StatusInternalServerError) 143 | return 144 | } 145 | } 146 | 147 | func (p *Plugin) httpMeetingDaysAutocomplete(w http.ResponseWriter, r *http.Request, listCommand bool) { 148 | query := r.URL.Query() 149 | meeting, err := p.GetMeeting(query.Get("channel_id")) 150 | if err != nil { 151 | http.Error(w, fmt.Sprintf("Error getting meeting days: %s", err.Error()), http.StatusInternalServerError) 152 | p.API.LogDebug("Failed to find meeting for autocomplete", "error", err.Error(), "listCommand", listCommand) 153 | return 154 | } 155 | 156 | ret := make([]model.AutocompleteListItem, 0) 157 | 158 | helpText := "Queue this item " 159 | if listCommand { 160 | helpText = "List items " 161 | } 162 | 163 | for _, meetingDay := range meeting.Schedule { 164 | ret = append(ret, model.AutocompleteListItem{ 165 | Item: meetingDay.String(), 166 | HelpText: fmt.Sprintf(helpText+"for %s's meeting", meetingDay.String()), 167 | Hint: "(optional)", 168 | }) 169 | } 170 | ret = append(ret, model.AutocompleteListItem{ 171 | Item: "next-week", 172 | HelpText: fmt.Sprintf(helpText + "for the first meeting next week"), 173 | Hint: "(optional)", 174 | }) 175 | 176 | jsonBytes, err := json.Marshal(ret) 177 | if err != nil { 178 | http.Error(w, fmt.Sprintf("Error getting meeting days: %s", err.Error()), http.StatusInternalServerError) 179 | return 180 | } 181 | 182 | w.Header().Set("Content-Type", "application/json") 183 | if _, err = w.Write(jsonBytes); err != nil { 184 | http.Error(w, fmt.Sprintf("Error getting meeting days: %s", err.Error()), http.StatusInternalServerError) 185 | return 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /webapp/src/components/meeting_settings/meeting_settings.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import {Modal} from 'react-bootstrap'; 5 | 6 | export default class MeetingSettingsModal extends React.PureComponent { 7 | static propTypes = { 8 | visible: PropTypes.bool.isRequired, 9 | channelId: PropTypes.string.isRequired, 10 | close: PropTypes.func.isRequired, 11 | meeting: PropTypes.object, 12 | fetchMeetingSettings: PropTypes.func.isRequired, 13 | saveMeetingSettings: PropTypes.func.isRequired, 14 | }; 15 | 16 | constructor(props) { 17 | super(props); 18 | 19 | this.state = { 20 | hashtag: '{{Jan02}}', 21 | weekdays: [1], 22 | }; 23 | } 24 | 25 | componentDidUpdate(prevProps) { 26 | if (this.props.channelId && this.props.channelId !== prevProps.channelId) { 27 | this.props.fetchMeetingSettings(this.props.channelId); 28 | } 29 | 30 | if (this.props.meeting && this.props.meeting !== prevProps.meeting) { 31 | // eslint-disable-next-line react/no-did-update-set-state 32 | this.setState({ 33 | hashtag: this.props.meeting.hashtagFormat, 34 | weekdays: this.props.meeting.schedule || [], 35 | }); 36 | } 37 | } 38 | 39 | handleHashtagChange = (e) => { 40 | this.setState({ 41 | hashtag: e.target.value, 42 | }); 43 | } 44 | 45 | handleCheckboxChanged = (e) => { 46 | const changeday = Number(e.target.value); 47 | let changedWeekdays = Object.assign([], this.state.weekdays); 48 | 49 | if (e.target.checked && !this.state.weekdays.includes(changeday)) { 50 | // Add the checked day 51 | changedWeekdays = [...changedWeekdays, changeday]; 52 | } else if (!e.target.checked && this.state.weekdays.includes(changeday)) { 53 | // Remove the unchecked day 54 | changedWeekdays.splice(changedWeekdays.indexOf(changeday), 1); 55 | } 56 | 57 | this.setState({ 58 | weekdays: changedWeekdays, 59 | }); 60 | } 61 | 62 | onSave = () => { 63 | this.props.saveMeetingSettings({ 64 | channelId: this.props.channelId, 65 | hashtagFormat: this.state.hashtag, 66 | schedule: this.state.weekdays.sort(), 67 | }); 68 | 69 | this.props.close(); 70 | } 71 | 72 | getDaysCheckboxes() { 73 | const weekDays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; 74 | 75 | const checkboxes = weekDays.map((weekday, i) => { 76 | return ( 77 | ); 89 | }); 90 | 91 | return checkboxes; 92 | } 93 | 94 | render() { 95 | return ( 96 | 103 | 104 | 108 | {'Channel Agenda Settings'} 109 | 110 | 111 | 112 |
113 | 116 |
117 | {this.getDaysCheckboxes()} 118 |
119 |
120 |
121 | 122 | 127 |

{'Hashtag is formatted using the '} 128 | {'Go time package.'} 133 | {' Embed a date by surrounding what January 2, 2006 would look like with double curly braces, i.e. {{Jan02}}'} 134 |

135 |
136 |
137 | 138 | 145 | 152 | 153 |
154 | ); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /server/meeting_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "reflect" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/mattermost/mattermost-server/v6/model" 11 | "github.com/mattermost/mattermost-server/v6/plugin/plugintest" 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | func assertNextWeekdayDate(meetingDay time.Weekday, nextWeek bool) *time.Time { 16 | weekDay, err := nextWeekdayDate(meetingDay, nextWeek) 17 | if err != nil { 18 | panic(err) 19 | } 20 | return weekDay 21 | } 22 | 23 | func TestPlugin_GenerateHashtag(t *testing.T) { 24 | tAssert := assert.New(t) 25 | mPlugin := Plugin{} 26 | api := &plugintest.API{} 27 | mPlugin.SetAPI(api) 28 | 29 | type args struct { 30 | nextWeek bool 31 | meeting *Meeting 32 | } 33 | tests := []struct { 34 | name string 35 | args args 36 | want string 37 | wantErr bool 38 | }{ 39 | { 40 | name: "No Date Formatting", 41 | args: args{ 42 | nextWeek: true, 43 | meeting: &Meeting{ 44 | ChannelID: "Developers", 45 | Schedule: []time.Weekday{time.Thursday}, 46 | HashtagFormat: "Developers", 47 | }}, 48 | want: "#Developers", 49 | wantErr: false, 50 | }, 51 | { 52 | name: "Only Date Formatting", 53 | args: args{ 54 | nextWeek: true, 55 | meeting: &Meeting{ 56 | ChannelID: "QA", 57 | Schedule: []time.Weekday{time.Wednesday}, 58 | HashtagFormat: "{{Jan02}}", 59 | }}, 60 | want: "#" + assertNextWeekdayDate(time.Wednesday, true).Format("Jan02"), 61 | wantErr: false, 62 | }, 63 | { 64 | name: "Date Formatting with Prefix", 65 | args: args{ 66 | nextWeek: true, 67 | meeting: &Meeting{ 68 | ChannelID: "QA Backend", 69 | Schedule: []time.Weekday{time.Monday}, 70 | HashtagFormat: "QA-{{January 02 2006}}", 71 | }}, 72 | want: "#QA-" + assertNextWeekdayDate(time.Monday, true).Format("January 02 2006"), 73 | wantErr: false, 74 | }, 75 | { 76 | name: "Date Formatting with Postfix", 77 | args: args{ 78 | nextWeek: false, 79 | meeting: &Meeting{ 80 | ChannelID: "QA FrontEnd", 81 | Schedule: []time.Weekday{time.Monday}, 82 | HashtagFormat: "{{January 02 2006}}.vue", 83 | }}, 84 | want: "#" + assertNextWeekdayDate(time.Monday, false).Format("January 02 2006") + ".vue", 85 | wantErr: false, 86 | }, 87 | { 88 | name: "Date Formatting with Prefix and Postfix", 89 | args: args{ 90 | nextWeek: false, 91 | meeting: &Meeting{ 92 | ChannelID: "QA Middleware", 93 | Schedule: []time.Weekday{time.Monday}, 94 | HashtagFormat: "React {{January 02 2006}} Born", 95 | }}, 96 | want: "#React " + assertNextWeekdayDate(time.Monday, false).Format("January 02 2006") + " Born", 97 | wantErr: false, 98 | }, 99 | { 100 | name: "Date Formatting while ignoring Golang Time Formatting without brackets", 101 | args: args{ 102 | nextWeek: false, 103 | meeting: &Meeting{ 104 | ChannelID: "Coffee Time", 105 | Schedule: []time.Weekday{time.Monday}, 106 | HashtagFormat: "January 02 2006 {{January 02 2006}} January 02 2006", 107 | }}, 108 | want: "#January 02 2006 " + assertNextWeekdayDate(time.Monday, false).Format("January 02 2006") + " January 02 2006", 109 | wantErr: false, 110 | }, 111 | { 112 | name: "Date Formatting whitespace", 113 | args: args{ 114 | nextWeek: false, 115 | meeting: &Meeting{ 116 | ChannelID: "Dates with Spaces", 117 | Schedule: []time.Weekday{time.Monday}, 118 | HashtagFormat: "{{ January 02 2006 }}", 119 | }}, 120 | want: "#" + assertNextWeekdayDate(time.Monday, false).Format("January 02 2006"), 121 | wantErr: false, 122 | }, 123 | { 124 | name: "Date Formatting ANSI", 125 | args: args{ 126 | nextWeek: false, 127 | meeting: &Meeting{ 128 | ChannelID: "Dates", 129 | Schedule: []time.Weekday{time.Monday}, 130 | HashtagFormat: "{{ Mon Jan _2 }}", 131 | }}, 132 | want: "#" + assertNextWeekdayDate(time.Monday, false).Format("Mon Jan _2"), 133 | wantErr: false, 134 | }, 135 | } 136 | for _, tt := range tests { 137 | t.Run(tt.name, func(t *testing.T) { 138 | jsonMeeting, err := json.Marshal(tt.args.meeting) 139 | tAssert.Nil(err) 140 | api.On("KVGet", tt.args.meeting.ChannelID).Return(jsonMeeting, nil) 141 | got, err := mPlugin.GenerateHashtag(tt.args.meeting.ChannelID, tt.args.nextWeek, -1) 142 | if (err != nil) != tt.wantErr { 143 | t.Errorf("GenerateHashtag() error = %v, wantErr %v", err, tt.wantErr) 144 | return 145 | } 146 | if got != tt.want { 147 | t.Errorf("GenerateHashtag() got = %v, want %v", got, tt.want) 148 | } 149 | }) 150 | } 151 | } 152 | 153 | func TestPlugin_GetMeeting(t *testing.T) { 154 | tAssert := assert.New(t) 155 | mPlugin := Plugin{} 156 | api := &plugintest.API{} 157 | mPlugin.SetAPI(api) 158 | 159 | type args struct { 160 | channelID string 161 | channelName string 162 | storeMeeting *Meeting 163 | } 164 | tests := []struct { 165 | name string 166 | args args 167 | want *Meeting 168 | wantErr bool 169 | }{ 170 | { 171 | name: "Test Short Name", 172 | args: args{ 173 | channelID: "#short.name.channel", 174 | channelName: "Short", 175 | storeMeeting: nil, 176 | }, 177 | want: &Meeting{ 178 | Schedule: []time.Weekday{time.Thursday}, 179 | HashtagFormat: "Short-{{ Jan02 }}", 180 | ChannelID: "#short.name.channel", 181 | }, 182 | wantErr: false, 183 | }, 184 | { 185 | name: "Test Log Name", 186 | args: args{ 187 | channelID: "#long.name.channel", 188 | channelName: "Very Long Channel Name", 189 | storeMeeting: nil, 190 | }, 191 | want: &Meeting{ 192 | Schedule: []time.Weekday{time.Thursday}, 193 | HashtagFormat: "Very Long Chann-{{ Jan02 }}", 194 | ChannelID: "#long.name.channel", 195 | }, 196 | wantErr: false, 197 | }, 198 | } 199 | for _, tt := range tests { 200 | t.Run(tt.name, func(t *testing.T) { 201 | if tt.args.storeMeeting != nil { 202 | jsonMeeting, err := json.Marshal(tt.args.storeMeeting) 203 | tAssert.Nil(err) 204 | api.On("KVGet", tt.args.channelID).Return(jsonMeeting, nil) 205 | } else { 206 | api.On("KVGet", tt.args.channelID).Return(nil, nil) 207 | } 208 | api.On("GetChannel", tt.args.channelID).Return(GenerateFakeChannel(tt.args.channelID, tt.args.channelName)) 209 | got, err := mPlugin.GetMeeting(tt.args.channelID) 210 | if (err != nil) != tt.wantErr { 211 | t.Errorf("GetMeeting() error = %v, wantErr %v", err, tt.wantErr) 212 | return 213 | } 214 | if !reflect.DeepEqual(got, tt.want) { 215 | t.Errorf("GetMeeting() got = %v, want %v", got, tt.want) 216 | } 217 | }) 218 | } 219 | } 220 | 221 | func GenerateFakeChannel(channelID, name string) (channel *model.Channel, appError *model.AppError) { 222 | channel = &model.Channel{ 223 | Id: channelID, 224 | DisplayName: strings.ToTitle(name), 225 | Name: name, 226 | CreatorId: "test", 227 | } 228 | return 229 | } 230 | -------------------------------------------------------------------------------- /server/command.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | "time" 8 | 9 | "github.com/mattermost/mattermost-server/v6/model" 10 | "github.com/mattermost/mattermost-server/v6/plugin" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | const ( 15 | commandTriggerAgenda = "agenda" 16 | 17 | wsEventList = "list" 18 | ) 19 | 20 | // ParsedMeetingMessage is meeting message after being parsed 21 | type ParsedMeetingMessage struct { 22 | date string 23 | number string 24 | textMessage string 25 | } 26 | 27 | const helpCommandText = "###### Mattermost Agenda Plugin - Slash Command Help\n" + 28 | "The Agenda plugin lets you queue up meeting topics for channel discussion at a later time. When your meeting happens, you can click on the Hashtag to see all agenda items in the RHS. \n" + 29 | "To configure the agenda for this channel, click on the Channel Name in Mattermost to access the channel options menu and select `Agenda Settings`" + 30 | "\n* `/agenda queue [weekday (optional)] message` - Queue `message` as a topic on the next meeting. If `weekday` is provided, it will queue for the meeting for. \n" + 31 | "* `/agenda list [weekday(optional)]` - Show a list of items queued for the next meeting. If `next-week` is provided, it will list the agenda for the next calendar week. \n" + 32 | "* `/agenda setting ` - Update the setting with the given value. Field can be one of `schedule` or `hashtag` \n" + 33 | "How can we make this better? Submit an issue to the [Agenda Plugin repo here](https://github.com/mattermost/mattermost-plugin-agenda/issues) \n" 34 | 35 | func (p *Plugin) registerCommands() error { 36 | if err := p.API.RegisterCommand(createAgendaCommand()); err != nil { 37 | return errors.Wrapf(err, "failed to register %s command", commandTriggerAgenda) 38 | } 39 | 40 | return nil 41 | } 42 | 43 | // ExecuteCommand executes a command that has been previously registered via the RegisterCommand 44 | func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*model.CommandResponse, *model.AppError) { 45 | split := strings.Fields(args.Command) 46 | 47 | if len(split) < 2 { 48 | return responsef("Missing command. You can try queue, list, setting"), nil 49 | } 50 | 51 | action := split[1] 52 | 53 | switch action { 54 | case "list": 55 | return p.executeCommandList(args), nil 56 | 57 | case "queue": 58 | return p.executeCommandQueue(args), nil 59 | 60 | case "setting": 61 | return p.executeCommandSetting(args), nil 62 | 63 | case "help": 64 | return p.executeCommandHelp(args), nil 65 | } 66 | 67 | return responsef("Unknown action: %s", action), nil 68 | } 69 | 70 | func (p *Plugin) executeCommandList(args *model.CommandArgs) *model.CommandResponse { 71 | split := strings.Fields(args.Command) 72 | nextWeek := len(split) > 2 && split[2] == "next-week" 73 | 74 | weekday := -1 75 | if !nextWeek && len(split) > 2 { 76 | parsedWeekday, _ := parseSchedule(split[2]) 77 | weekday = int(parsedWeekday) 78 | } 79 | 80 | hashtag, err := p.GenerateHashtag(args.ChannelId, nextWeek, weekday) 81 | if err != nil { 82 | return responsef("Error calculating hashtags") 83 | } 84 | 85 | // Send a websocket event to the web app that will open the RHS 86 | p.API.PublishWebSocketEvent( 87 | wsEventList, 88 | map[string]interface{}{ 89 | "hashtag": hashtag, 90 | }, 91 | &model.WebsocketBroadcast{UserId: args.UserId}, 92 | ) 93 | 94 | return &model.CommandResponse{} 95 | } 96 | 97 | func (p *Plugin) executeCommandSetting(args *model.CommandArgs) *model.CommandResponse { 98 | // settings: hashtag, schedule 99 | split := strings.Fields(args.Command) 100 | 101 | if len(split) < 4 { 102 | return responsef("Setting parameters missing") 103 | } 104 | 105 | field := split[2] 106 | value := split[3] 107 | 108 | meeting, err := p.GetMeeting(args.ChannelId) 109 | if err != nil { 110 | return responsef("Error getting meeting information for this channel") 111 | } 112 | 113 | switch field { 114 | case "schedule": 115 | // Set schedule 116 | weekdayInt, err := parseSchedule(value) 117 | if err != nil { 118 | return responsef(err.Error()) 119 | } 120 | meeting.Schedule = []time.Weekday{weekdayInt} 121 | 122 | case "hashtag": 123 | // Set hashtag 124 | meeting.HashtagFormat = value 125 | default: 126 | return responsef("Unknown setting %s", field) 127 | } 128 | 129 | if err := p.SaveMeeting(meeting); err != nil { 130 | return responsef("Error saving setting") 131 | } 132 | 133 | return responsef("Updated setting %v to %v", field, value) 134 | } 135 | 136 | func (p *Plugin) executeCommandQueue(args *model.CommandArgs) *model.CommandResponse { 137 | split := strings.Fields(args.Command) 138 | 139 | if len(split) <= 2 { 140 | return responsef("Missing parameters for queue command") 141 | } 142 | 143 | meeting, err := p.GetMeeting(args.ChannelId) 144 | if err != nil { 145 | p.API.LogError("failed to get meeting for channel", "err", err.Error(), "channel_id", args.ChannelId) 146 | } 147 | 148 | nextWeek := false 149 | weekday := -1 150 | message := strings.Join(split[2:], " ") 151 | 152 | if split[2] == "next-week" { 153 | nextWeek = true 154 | } else { 155 | parsedWeekday, _ := parseSchedule(split[2]) 156 | weekday = int(parsedWeekday) 157 | } 158 | 159 | if nextWeek || weekday > -1 { 160 | message = strings.Join(split[3:], " ") 161 | } 162 | 163 | hashtag, err := p.GenerateHashtag(args.ChannelId, nextWeek, weekday) 164 | if err != nil { 165 | return responsef("Error calculating hashtags. Check the meeting settings for this channel.") 166 | } 167 | 168 | numQueueItems, itemErr := p.calculateQueueItemNumberAndUpdateOldItems(meeting, args, hashtag) 169 | if itemErr != nil { 170 | return responsef(itemErr.Error()) 171 | } 172 | 173 | _, appErr := p.API.CreatePost(&model.Post{ 174 | UserId: args.UserId, 175 | ChannelId: args.ChannelId, 176 | RootId: args.RootId, 177 | Message: fmt.Sprintf("#### %v %v) %v", hashtag, numQueueItems, message), 178 | }) 179 | if appErr != nil { 180 | return responsef("Error creating post: %s", appErr.Message) 181 | } 182 | 183 | return &model.CommandResponse{} 184 | } 185 | 186 | func parseMeetingPost(meeting *Meeting, post *model.Post) (string, ParsedMeetingMessage, error) { 187 | var ( 188 | prefix string 189 | hashtagDateFormat string 190 | ) 191 | if matchGroups := meetingDateFormatRegex.FindStringSubmatch(meeting.HashtagFormat); len(matchGroups) == 4 { 192 | prefix = matchGroups[1] 193 | hashtagDateFormat = strings.TrimSpace(matchGroups[2]) 194 | } else { 195 | return "", ParsedMeetingMessage{}, errors.New("error Parsing meeting post") 196 | } 197 | 198 | var ( 199 | messageRegexFormat, err = regexp.Compile(fmt.Sprintf(`(?m)^#### #%s(?P.*) ([0-9]+)\) (?P.*)?$`, prefix)) 200 | ) 201 | 202 | if err != nil { 203 | return "", ParsedMeetingMessage{}, err 204 | } 205 | matchGroups := messageRegexFormat.FindStringSubmatch(post.Message) 206 | if len(matchGroups) == 4 { 207 | parsedMeetingMessage := ParsedMeetingMessage{ 208 | date: matchGroups[1], 209 | number: matchGroups[2], 210 | textMessage: matchGroups[3], 211 | } 212 | 213 | return hashtagDateFormat, parsedMeetingMessage, nil 214 | } 215 | 216 | return hashtagDateFormat, ParsedMeetingMessage{}, errors.New("failed to parse meeting post's header") 217 | } 218 | 219 | func (p *Plugin) executeCommandHelp(args *model.CommandArgs) *model.CommandResponse { 220 | return responsef(helpCommandText) 221 | } 222 | 223 | func responsef(format string, args ...interface{}) *model.CommandResponse { 224 | return &model.CommandResponse{ 225 | ResponseType: model.CommandResponseTypeEphemeral, 226 | Text: fmt.Sprintf(format, args...), 227 | Type: model.PostTypeDefault, 228 | } 229 | } 230 | 231 | func createAgendaCommand() *model.Command { 232 | agenda := model.NewAutocompleteData(commandTriggerAgenda, "[command]", "Available commands: list, queue, setting, help") 233 | 234 | list := model.NewAutocompleteData("list", "", "Show a list of items queued for the next meeting") 235 | list.AddDynamicListArgument("Day of the week for when to queue the meeting", "/api/v1/list-meeting-days-autocomplete", false) 236 | agenda.AddCommand(list) 237 | 238 | queue := model.NewAutocompleteData("queue", "", "Queue `message` as a topic on the next meeting.") 239 | queue.AddDynamicListArgument("Day of the week for when to queue the meeting", "/api/v1/meeting-days-autocomplete", false) 240 | queue.AddTextArgument("Message for the next meeting date.", "[message]", "") 241 | agenda.AddCommand(queue) 242 | 243 | setting := model.NewAutocompleteData("setting", "", "Update the setting.") 244 | schedule := model.NewAutocompleteData("schedule", "", "Update schedule.") 245 | schedule.AddStaticListArgument("weekday", true, []model.AutocompleteListItem{ 246 | {Item: "Monday"}, 247 | {Item: "Tuesday"}, 248 | {Item: "Wednesday"}, 249 | {Item: "Thursday"}, 250 | {Item: "Friday"}, 251 | {Item: "Saturday"}, 252 | {Item: "Sunday"}, 253 | }) 254 | setting.AddCommand(schedule) 255 | hashtag := model.NewAutocompleteData("hashtag", "", "Update hastag.") 256 | hashtag.AddTextArgument("input hashtag", "Default: Jan02", "") 257 | setting.AddCommand(hashtag) 258 | agenda.AddCommand(setting) 259 | 260 | help := model.NewAutocompleteData("help", "", "Mattermost Agenda plugin slash command help") 261 | agenda.AddCommand(help) 262 | return &model.Command{ 263 | Trigger: commandTriggerAgenda, 264 | AutoComplete: true, 265 | AutoCompleteDesc: "Available commands: list, queue, setting, help", 266 | AutoCompleteHint: "[command]", 267 | AutocompleteData: agenda, 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GO ?= $(shell command -v go 2> /dev/null) 2 | NPM ?= $(shell command -v npm 2> /dev/null) 3 | CURL ?= $(shell command -v curl 2> /dev/null) 4 | MM_DEBUG ?= 5 | MANIFEST_FILE ?= plugin.json 6 | GOPATH ?= $(shell go env GOPATH) 7 | GO_TEST_FLAGS ?= -race 8 | GO_BUILD_FLAGS ?= 9 | MM_UTILITIES_DIR ?= ../mattermost-utilities 10 | DLV_DEBUG_PORT := 2346 11 | DEFAULT_GOOS := $(shell go env GOOS) 12 | DEFAULT_GOARCH := $(shell go env GOARCH) 13 | 14 | export GO111MODULE=on 15 | 16 | # You can include assets this directory into the bundle. This can be e.g. used to include profile pictures. 17 | ASSETS_DIR ?= assets 18 | 19 | ## Define the default target (make all) 20 | .PHONY: default 21 | default: all 22 | 23 | # Verify environment, and define PLUGIN_ID, PLUGIN_VERSION, HAS_SERVER and HAS_WEBAPP as needed. 24 | include build/setup.mk 25 | include build/legacy.mk 26 | 27 | BUNDLE_NAME ?= $(PLUGIN_ID)-$(PLUGIN_VERSION).tar.gz 28 | 29 | # Include custom makefile, if present 30 | ifneq ($(wildcard build/custom.mk),) 31 | include build/custom.mk 32 | endif 33 | 34 | ## Checks the code style, tests, builds and bundles the plugin. 35 | .PHONY: all 36 | all: check-style test dist 37 | 38 | ## Runs eslint and golangci-lint 39 | .PHONY: check-style 40 | check-style: webapp/node_modules 41 | @echo Checking for style guide compliance 42 | 43 | ifneq ($(HAS_WEBAPP),) 44 | cd webapp && npm run lint 45 | cd webapp && npm run check-types 46 | endif 47 | 48 | ifneq ($(HAS_SERVER),) 49 | @if ! [ -x "$$(command -v golangci-lint)" ]; then \ 50 | echo "golangci-lint is not installed. Please see https://github.com/golangci/golangci-lint#install for installation instructions."; \ 51 | exit 1; \ 52 | fi; \ 53 | 54 | @echo Running golangci-lint 55 | golangci-lint run ./... 56 | endif 57 | 58 | ## Builds the server, if it exists, for all supported architectures, unless MM_SERVICESETTINGS_ENABLEDEVELOPER is set 59 | .PHONY: server 60 | server: 61 | ifneq ($(HAS_SERVER),) 62 | mkdir -p server/dist; 63 | ifeq ($(MM_DEBUG),) 64 | ifneq ($(MM_SERVICESETTINGS_ENABLEDEVELOPER),) 65 | @echo Building plugin only for $(DEFAULT_GOOS)-$(DEFAULT_GOARCH) because MM_SERVICESETTINGS_ENABLEDEVELOPER is enabled 66 | cd server && $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-$(DEFAULT_GOOS)-$(DEFAULT_GOARCH); 67 | else 68 | cd server && env GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-linux-amd64; 69 | cd server && env GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-linux-arm64; 70 | cd server && env GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-darwin-amd64; 71 | cd server && env GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-darwin-arm64; 72 | cd server && env GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -trimpath -o dist/plugin-windows-amd64.exe; 73 | endif 74 | else 75 | $(info DEBUG mode is on; to disable, unset MM_DEBUG) 76 | ifneq ($(MM_SERVICESETTINGS_ENABLEDEVELOPER),) 77 | @echo Building plugin only for $(DEFAULT_GOOS)-$(DEFAULT_GOARCH) because MM_SERVICESETTINGS_ENABLEDEVELOPER is enabled 78 | cd server && $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-$(DEFAULT_GOOS)-$(DEFAULT_GOARCH); 79 | else 80 | cd server && env GOOS=linux GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-linux-amd64; 81 | cd server && env GOOS=linux GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-linux-arm64; 82 | cd server && env GOOS=darwin GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-darwin-amd64; 83 | cd server && env GOOS=darwin GOARCH=arm64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-darwin-arm64; 84 | cd server && env GOOS=windows GOARCH=amd64 $(GO) build $(GO_BUILD_FLAGS) -gcflags "all=-N -l" -trimpath -o dist/plugin-windows-amd64.exe; 85 | endif 86 | endif 87 | endif 88 | 89 | ## Ensures NPM dependencies are installed without having to run this all the time. 90 | webapp/node_modules: $(wildcard webapp/package.json) 91 | ifneq ($(HAS_WEBAPP),) 92 | cd webapp && $(NPM) install 93 | touch $@ 94 | endif 95 | 96 | ## Builds the webapp, if it exists. 97 | .PHONY: webapp 98 | webapp: webapp/node_modules 99 | ifneq ($(HAS_WEBAPP),) 100 | ifeq ($(MM_DEBUG),) 101 | cd webapp && $(NPM) run build; 102 | else 103 | cd webapp && $(NPM) run debug; 104 | endif 105 | endif 106 | 107 | ## Generates a tar bundle of the plugin for install. 108 | .PHONY: bundle 109 | bundle: 110 | rm -rf dist/ 111 | mkdir -p dist/$(PLUGIN_ID) 112 | cp $(MANIFEST_FILE) dist/$(PLUGIN_ID)/ 113 | ifneq ($(wildcard $(ASSETS_DIR)/.),) 114 | cp -r $(ASSETS_DIR) dist/$(PLUGIN_ID)/ 115 | endif 116 | ifneq ($(HAS_PUBLIC),) 117 | cp -r public dist/$(PLUGIN_ID)/ 118 | endif 119 | ifneq ($(HAS_SERVER),) 120 | mkdir -p dist/$(PLUGIN_ID)/server 121 | cp -r server/dist dist/$(PLUGIN_ID)/server/ 122 | endif 123 | ifneq ($(HAS_WEBAPP),) 124 | mkdir -p dist/$(PLUGIN_ID)/webapp 125 | cp -r webapp/dist dist/$(PLUGIN_ID)/webapp/ 126 | endif 127 | cd dist && tar -cvzf $(BUNDLE_NAME) $(PLUGIN_ID) 128 | 129 | @echo plugin built at: dist/$(BUNDLE_NAME) 130 | 131 | ## Builds and bundles the plugin. 132 | .PHONY: dist 133 | dist: server webapp bundle 134 | 135 | ## Builds and installs the plugin to a server. 136 | .PHONY: deploy 137 | deploy: dist 138 | ./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME) 139 | 140 | ## Builds and installs the plugin to a server, updating the webapp automatically when changed. 141 | .PHONY: watch 142 | watch: server bundle 143 | ifeq ($(MM_DEBUG),) 144 | cd webapp && $(NPM) run build:watch 145 | else 146 | cd webapp && $(NPM) run debug:watch 147 | endif 148 | 149 | ## Installs a previous built plugin with updated webpack assets to a server. 150 | .PHONY: deploy-from-watch 151 | deploy-from-watch: bundle 152 | ./build/bin/pluginctl deploy $(PLUGIN_ID) dist/$(BUNDLE_NAME) 153 | 154 | ## Setup dlv for attaching, identifying the plugin PID for other targets. 155 | .PHONY: setup-attach 156 | setup-attach: 157 | $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) 158 | $(eval NUM_PID := $(shell echo -n ${PLUGIN_PID} | wc -w)) 159 | 160 | @if [ ${NUM_PID} -gt 2 ]; then \ 161 | echo "** There is more than 1 plugin process running. Run 'make kill reset' to restart just one."; \ 162 | exit 1; \ 163 | fi 164 | 165 | ## Check if setup-attach succeeded. 166 | .PHONY: check-attach 167 | check-attach: 168 | @if [ -z ${PLUGIN_PID} ]; then \ 169 | echo "Could not find plugin PID; the plugin is not running. Exiting."; \ 170 | exit 1; \ 171 | else \ 172 | echo "Located Plugin running with PID: ${PLUGIN_PID}"; \ 173 | fi 174 | 175 | ## Attach dlv to an existing plugin instance. 176 | .PHONY: attach 177 | attach: setup-attach check-attach 178 | dlv attach ${PLUGIN_PID} 179 | 180 | ## Attach dlv to an existing plugin instance, exposing a headless instance on $DLV_DEBUG_PORT. 181 | .PHONY: attach-headless 182 | attach-headless: setup-attach check-attach 183 | dlv attach ${PLUGIN_PID} --listen :$(DLV_DEBUG_PORT) --headless=true --api-version=2 --accept-multiclient 184 | 185 | ## Detach dlv from an existing plugin instance, if previously attached. 186 | .PHONY: detach 187 | detach: setup-attach 188 | @DELVE_PID=$(shell ps aux | grep "dlv attach ${PLUGIN_PID}" | grep -v "grep" | awk -F " " '{print $$2}') && \ 189 | if [ "$$DELVE_PID" -gt 0 ] > /dev/null 2>&1 ; then \ 190 | echo "Located existing delve process running with PID: $$DELVE_PID. Killing." ; \ 191 | kill -9 $$DELVE_PID ; \ 192 | fi 193 | 194 | ## Runs any lints and unit tests defined for the server and webapp, if they exist. 195 | .PHONY: test 196 | test: webapp/node_modules 197 | ifneq ($(HAS_SERVER),) 198 | $(GO) test -v $(GO_TEST_FLAGS) ./server/... 199 | endif 200 | ifneq ($(HAS_WEBAPP),) 201 | cd webapp && $(NPM) run test; 202 | endif 203 | ifneq ($(wildcard ./build/sync/plan/.),) 204 | cd ./build/sync && $(GO) test -v $(GO_TEST_FLAGS) ./... 205 | endif 206 | 207 | ## Creates a coverage report for the server code. 208 | .PHONY: coverage 209 | coverage: webapp/node_modules 210 | ifneq ($(HAS_SERVER),) 211 | $(GO) test $(GO_TEST_FLAGS) -coverprofile=server/coverage.txt ./... 212 | $(GO) tool cover -html=server/coverage.txt 213 | endif 214 | 215 | ## Extract strings for translation from the source code. 216 | .PHONY: i18n-extract 217 | i18n-extract: 218 | ifneq ($(HAS_WEBAPP),) 219 | ifeq ($(HAS_MM_UTILITIES),) 220 | @echo "You must clone github.com/mattermost/mattermost-utilities repo in .. to use this command" 221 | else 222 | cd $(MM_UTILITIES_DIR) && npm install && npm run babel && node mmjstool/build/index.js i18n extract-webapp --webapp-dir $(PWD)/webapp 223 | endif 224 | endif 225 | 226 | ## Disable the plugin. 227 | .PHONY: disable 228 | disable: detach 229 | ./build/bin/pluginctl disable $(PLUGIN_ID) 230 | 231 | ## Enable the plugin. 232 | .PHONY: enable 233 | enable: 234 | ./build/bin/pluginctl enable $(PLUGIN_ID) 235 | 236 | ## Reset the plugin, effectively disabling and re-enabling it on the server. 237 | .PHONY: reset 238 | reset: detach 239 | ./build/bin/pluginctl reset $(PLUGIN_ID) 240 | 241 | ## Kill all instances of the plugin, detaching any existing dlv instance. 242 | .PHONY: kill 243 | kill: detach 244 | $(eval PLUGIN_PID := $(shell ps aux | grep "plugins/${PLUGIN_ID}" | grep -v "grep" | awk -F " " '{print $$2}')) 245 | 246 | @for PID in ${PLUGIN_PID}; do \ 247 | echo "Killing plugin pid $$PID"; \ 248 | kill -9 $$PID; \ 249 | done; \ 250 | 251 | ## Clean removes all build artifacts. 252 | .PHONY: clean 253 | clean: 254 | rm -fr dist/ 255 | ifneq ($(HAS_SERVER),) 256 | rm -fr server/coverage.txt 257 | rm -fr server/dist 258 | endif 259 | ifneq ($(HAS_WEBAPP),) 260 | rm -fr webapp/junit.xml 261 | rm -fr webapp/dist 262 | rm -fr webapp/node_modules 263 | endif 264 | rm -fr build/bin/ 265 | 266 | # Help documentation à la https://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 267 | help: 268 | @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 269 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /webapp/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:react-hooks/recommended" 5 | ], 6 | "parserOptions": { 7 | "ecmaVersion": 8, 8 | "sourceType": "module", 9 | "ecmaFeatures": { 10 | "jsx": true, 11 | "impliedStrict": true, 12 | "modules": true, 13 | "experimentalObjectRestSpread": true 14 | } 15 | }, 16 | "parser": "babel-eslint", 17 | "plugins": [ 18 | "react", 19 | "import" 20 | ], 21 | "env": { 22 | "browser": true, 23 | "node": true, 24 | "jquery": true, 25 | "es6": true, 26 | "jest": true 27 | }, 28 | "globals": { 29 | "jest": true, 30 | "describe": true, 31 | "it": true, 32 | "expect": true, 33 | "before": true, 34 | "after": true, 35 | "beforeEach": true 36 | }, 37 | "settings": { 38 | "import/resolver": "webpack" 39 | }, 40 | "rules": { 41 | "array-bracket-spacing": [ 42 | 2, 43 | "never" 44 | ], 45 | "array-callback-return": 2, 46 | "arrow-body-style": 0, 47 | "arrow-parens": [ 48 | 2, 49 | "always" 50 | ], 51 | "arrow-spacing": [ 52 | 2, 53 | { 54 | "before": true, 55 | "after": true 56 | } 57 | ], 58 | "block-scoped-var": 2, 59 | "brace-style": [ 60 | 2, 61 | "1tbs", 62 | { 63 | "allowSingleLine": false 64 | } 65 | ], 66 | "capitalized-comments": 0, 67 | "class-methods-use-this": 0, 68 | "comma-dangle": [ 69 | 2, 70 | "always-multiline" 71 | ], 72 | "comma-spacing": [ 73 | 2, 74 | { 75 | "before": false, 76 | "after": true 77 | } 78 | ], 79 | "comma-style": [ 80 | 2, 81 | "last" 82 | ], 83 | "complexity": [ 84 | 0, 85 | 10 86 | ], 87 | "computed-property-spacing": [ 88 | 2, 89 | "never" 90 | ], 91 | "consistent-return": 2, 92 | "consistent-this": [ 93 | 2, 94 | "self" 95 | ], 96 | "constructor-super": 2, 97 | "curly": [ 98 | 2, 99 | "all" 100 | ], 101 | "dot-location": [ 102 | 2, 103 | "object" 104 | ], 105 | "dot-notation": 2, 106 | "eqeqeq": [ 107 | 2, 108 | "smart" 109 | ], 110 | "func-call-spacing": [ 111 | 2, 112 | "never" 113 | ], 114 | "func-name-matching": 0, 115 | "func-names": 2, 116 | "func-style": [ 117 | 2, 118 | "declaration", 119 | { 120 | "allowArrowFunctions": true 121 | } 122 | ], 123 | "generator-star-spacing": [ 124 | 2, 125 | { 126 | "before": false, 127 | "after": true 128 | } 129 | ], 130 | "global-require": 2, 131 | "guard-for-in": 2, 132 | "id-blacklist": 0, 133 | "import/no-unresolved": 2, 134 | "import/order": [ 135 | "error", 136 | { 137 | "newlines-between": "always-and-inside-groups", 138 | "groups": [ 139 | "builtin", 140 | "external", 141 | [ 142 | "internal", 143 | "parent" 144 | ], 145 | "sibling", 146 | "index" 147 | ] 148 | } 149 | ], 150 | "indent": [ 151 | 2, 152 | 4, 153 | { 154 | "SwitchCase": 0 155 | } 156 | ], 157 | "jsx-quotes": [ 158 | 2, 159 | "prefer-single" 160 | ], 161 | "key-spacing": [ 162 | 2, 163 | { 164 | "beforeColon": false, 165 | "afterColon": true, 166 | "mode": "strict" 167 | } 168 | ], 169 | "keyword-spacing": [ 170 | 2, 171 | { 172 | "before": true, 173 | "after": true, 174 | "overrides": {} 175 | } 176 | ], 177 | "line-comment-position": 0, 178 | "linebreak-style": 2, 179 | "lines-around-comment": [ 180 | 2, 181 | { 182 | "beforeBlockComment": true, 183 | "beforeLineComment": true, 184 | "allowBlockStart": true, 185 | "allowBlockEnd": true 186 | } 187 | ], 188 | "max-lines": [ 189 | 1, 190 | { 191 | "max": 450, 192 | "skipBlankLines": true, 193 | "skipComments": false 194 | } 195 | ], 196 | "max-nested-callbacks": [ 197 | 2, 198 | { 199 | "max": 2 200 | } 201 | ], 202 | "max-statements-per-line": [ 203 | 2, 204 | { 205 | "max": 1 206 | } 207 | ], 208 | "multiline-ternary": [ 209 | 1, 210 | "never" 211 | ], 212 | "new-cap": 2, 213 | "new-parens": 2, 214 | "newline-before-return": 0, 215 | "newline-per-chained-call": 0, 216 | "no-alert": 2, 217 | "no-array-constructor": 2, 218 | "no-await-in-loop": 2, 219 | "no-caller": 2, 220 | "no-case-declarations": 2, 221 | "no-class-assign": 2, 222 | "no-compare-neg-zero": 2, 223 | "no-cond-assign": [ 224 | 2, 225 | "except-parens" 226 | ], 227 | "no-confusing-arrow": 2, 228 | "no-console": 2, 229 | "no-const-assign": 2, 230 | "no-constant-condition": 2, 231 | "no-debugger": 2, 232 | "no-div-regex": 2, 233 | "no-dupe-args": 2, 234 | "no-dupe-class-members": 2, 235 | "no-dupe-keys": 2, 236 | "no-duplicate-case": 2, 237 | "no-duplicate-imports": [ 238 | 2, 239 | { 240 | "includeExports": true 241 | } 242 | ], 243 | "no-else-return": 2, 244 | "no-empty": 2, 245 | "no-empty-function": 2, 246 | "no-empty-pattern": 2, 247 | "no-eval": 2, 248 | "no-ex-assign": 2, 249 | "no-extend-native": 2, 250 | "no-extra-bind": 2, 251 | "no-extra-label": 2, 252 | "no-extra-parens": 0, 253 | "no-extra-semi": 2, 254 | "no-fallthrough": 2, 255 | "no-floating-decimal": 2, 256 | "no-func-assign": 2, 257 | "no-global-assign": 2, 258 | "no-implicit-coercion": 2, 259 | "no-implicit-globals": 0, 260 | "no-implied-eval": 2, 261 | "no-inner-declarations": 0, 262 | "no-invalid-regexp": 2, 263 | "no-irregular-whitespace": 2, 264 | "no-iterator": 2, 265 | "no-labels": 2, 266 | "no-lone-blocks": 2, 267 | "no-lonely-if": 2, 268 | "no-loop-func": 2, 269 | "no-magic-numbers": [ 270 | 0, 271 | { 272 | "ignore": [ 273 | -1, 274 | 0, 275 | 1, 276 | 2 277 | ], 278 | "enforceConst": true, 279 | "detectObjects": true 280 | } 281 | ], 282 | "no-mixed-operators": [ 283 | 2, 284 | { 285 | "allowSamePrecedence": false 286 | } 287 | ], 288 | "no-mixed-spaces-and-tabs": 2, 289 | "no-multi-assign": 2, 290 | "no-multi-spaces": [ 291 | 2, 292 | { 293 | "exceptions": { 294 | "Property": false 295 | } 296 | } 297 | ], 298 | "no-multi-str": 0, 299 | "no-multiple-empty-lines": [ 300 | 2, 301 | { 302 | "max": 1 303 | } 304 | ], 305 | "no-native-reassign": 2, 306 | "no-negated-condition": 2, 307 | "no-nested-ternary": 2, 308 | "no-new": 2, 309 | "no-new-func": 2, 310 | "no-new-object": 2, 311 | "no-new-symbol": 2, 312 | "no-new-wrappers": 2, 313 | "no-octal-escape": 2, 314 | "no-param-reassign": 2, 315 | "no-process-env": 2, 316 | "no-process-exit": 2, 317 | "no-proto": 2, 318 | "no-redeclare": 2, 319 | "no-return-assign": [ 320 | 2, 321 | "always" 322 | ], 323 | "no-return-await": 2, 324 | "no-script-url": 2, 325 | "no-self-assign": [ 326 | 2, 327 | { 328 | "props": true 329 | } 330 | ], 331 | "no-self-compare": 2, 332 | "no-sequences": 2, 333 | "no-shadow": [ 334 | 2, 335 | { 336 | "hoist": "functions" 337 | } 338 | ], 339 | "no-shadow-restricted-names": 2, 340 | "no-spaced-func": 2, 341 | "no-tabs": 0, 342 | "no-template-curly-in-string": 2, 343 | "no-ternary": 0, 344 | "no-this-before-super": 2, 345 | "no-throw-literal": 2, 346 | "no-trailing-spaces": [ 347 | 2, 348 | { 349 | "skipBlankLines": false 350 | } 351 | ], 352 | "no-undef-init": 2, 353 | "no-undefined": 2, 354 | "no-underscore-dangle": 2, 355 | "no-unexpected-multiline": 2, 356 | "no-unmodified-loop-condition": 2, 357 | "no-unneeded-ternary": [ 358 | 2, 359 | { 360 | "defaultAssignment": false 361 | } 362 | ], 363 | "no-unreachable": 2, 364 | "no-unsafe-finally": 2, 365 | "no-unsafe-negation": 2, 366 | "no-unused-expressions": 2, 367 | "no-unused-vars": [ 368 | 2, 369 | { 370 | "vars": "all", 371 | "args": "after-used" 372 | } 373 | ], 374 | "no-use-before-define": [ 375 | 2, 376 | { 377 | "classes": false, 378 | "functions": false, 379 | "variables": false 380 | } 381 | ], 382 | "no-useless-computed-key": 2, 383 | "no-useless-concat": 2, 384 | "no-useless-constructor": 2, 385 | "no-useless-escape": 2, 386 | "no-useless-rename": 2, 387 | "no-useless-return": 2, 388 | "no-var": 0, 389 | "no-void": 2, 390 | "no-warning-comments": 1, 391 | "no-whitespace-before-property": 2, 392 | "no-with": 2, 393 | "object-curly-newline": 0, 394 | "object-curly-spacing": [ 395 | 2, 396 | "never" 397 | ], 398 | "object-property-newline": [ 399 | 2, 400 | { 401 | "allowMultiplePropertiesPerLine": true 402 | } 403 | ], 404 | "object-shorthand": [ 405 | 2, 406 | "always" 407 | ], 408 | "one-var": [ 409 | 2, 410 | "never" 411 | ], 412 | "one-var-declaration-per-line": 0, 413 | "operator-assignment": [ 414 | 2, 415 | "always" 416 | ], 417 | "operator-linebreak": [ 418 | 2, 419 | "after" 420 | ], 421 | "padded-blocks": [ 422 | 2, 423 | "never" 424 | ], 425 | "prefer-arrow-callback": 2, 426 | "prefer-const": 2, 427 | "prefer-destructuring": 0, 428 | "prefer-numeric-literals": 2, 429 | "prefer-promise-reject-errors": 2, 430 | "prefer-rest-params": 2, 431 | "prefer-spread": 2, 432 | "prefer-template": 0, 433 | "quote-props": [ 434 | 2, 435 | "as-needed" 436 | ], 437 | "quotes": [ 438 | 2, 439 | "single", 440 | "avoid-escape" 441 | ], 442 | "radix": 2, 443 | "react/display-name": [ 444 | 0, 445 | { 446 | "ignoreTranspilerName": false 447 | } 448 | ], 449 | "react/forbid-component-props": 0, 450 | "react/forbid-elements": [ 451 | 2, 452 | { 453 | "forbid": [ 454 | "embed" 455 | ] 456 | } 457 | ], 458 | "react/jsx-boolean-value": [ 459 | 2, 460 | "always" 461 | ], 462 | "react/jsx-closing-bracket-location": [ 463 | 2, 464 | { 465 | "location": "tag-aligned" 466 | } 467 | ], 468 | "react/jsx-curly-spacing": [ 469 | 2, 470 | "never" 471 | ], 472 | "react/jsx-equals-spacing": [ 473 | 2, 474 | "never" 475 | ], 476 | "react/jsx-filename-extension": 2, 477 | "react/jsx-first-prop-new-line": [ 478 | 2, 479 | "multiline" 480 | ], 481 | "react/jsx-handler-names": 0, 482 | "react/jsx-indent": [ 483 | 2, 484 | 4 485 | ], 486 | "react/jsx-indent-props": [ 487 | 2, 488 | 4 489 | ], 490 | "react/jsx-key": 2, 491 | "react/jsx-max-props-per-line": [ 492 | 2, 493 | { 494 | "maximum": 1 495 | } 496 | ], 497 | "react/jsx-no-bind": 0, 498 | "react/jsx-no-comment-textnodes": 2, 499 | "react/jsx-no-duplicate-props": [ 500 | 2, 501 | { 502 | "ignoreCase": false 503 | } 504 | ], 505 | "react/jsx-no-literals": 2, 506 | "react/jsx-no-target-blank": 2, 507 | "react/jsx-no-undef": 2, 508 | "react/jsx-pascal-case": 2, 509 | "react/jsx-tag-spacing": [ 510 | 2, 511 | { 512 | "closingSlash": "never", 513 | "beforeSelfClosing": "never", 514 | "afterOpening": "never" 515 | } 516 | ], 517 | "react/jsx-uses-react": 2, 518 | "react/jsx-uses-vars": 2, 519 | "react/jsx-wrap-multilines": 2, 520 | "react/no-array-index-key": 1, 521 | "react/no-children-prop": 2, 522 | "react/no-danger": 0, 523 | "react/no-danger-with-children": 2, 524 | "react/no-deprecated": 1, 525 | "react/no-did-mount-set-state": 2, 526 | "react/no-did-update-set-state": 2, 527 | "react/no-direct-mutation-state": 2, 528 | "react/no-find-dom-node": 1, 529 | "react/no-is-mounted": 2, 530 | "react/no-multi-comp": [ 531 | 2, 532 | { 533 | "ignoreStateless": true 534 | } 535 | ], 536 | "react/no-render-return-value": 2, 537 | "react/no-set-state": 0, 538 | "react/no-string-refs": 0, 539 | "react/no-unescaped-entities": 2, 540 | "react/no-unknown-property": 2, 541 | "react/no-unused-prop-types": [ 542 | 1, 543 | { 544 | "skipShapeProps": true 545 | } 546 | ], 547 | "react/prefer-es6-class": 2, 548 | "react/prefer-stateless-function": 2, 549 | "react/prop-types": [ 550 | 2, 551 | { 552 | "ignore": [ 553 | "location", 554 | "history", 555 | "component" 556 | ] 557 | } 558 | ], 559 | "react/require-default-props": 0, 560 | "react/require-optimization": 1, 561 | "react/require-render-return": 2, 562 | "react/self-closing-comp": 2, 563 | "react/sort-comp": 0, 564 | "react/style-prop-object": 2, 565 | "require-yield": 2, 566 | "rest-spread-spacing": [ 567 | 2, 568 | "never" 569 | ], 570 | "semi": [ 571 | 2, 572 | "always" 573 | ], 574 | "semi-spacing": [ 575 | 2, 576 | { 577 | "before": false, 578 | "after": true 579 | } 580 | ], 581 | "sort-imports": 0, 582 | "sort-keys": 0, 583 | "space-before-blocks": [ 584 | 2, 585 | "always" 586 | ], 587 | "space-before-function-paren": [ 588 | 2, 589 | { 590 | "anonymous": "never", 591 | "named": "never", 592 | "asyncArrow": "always" 593 | } 594 | ], 595 | "space-in-parens": [ 596 | 2, 597 | "never" 598 | ], 599 | "space-infix-ops": 2, 600 | "space-unary-ops": [ 601 | 2, 602 | { 603 | "words": true, 604 | "nonwords": false 605 | } 606 | ], 607 | "symbol-description": 2, 608 | "template-curly-spacing": [ 609 | 2, 610 | "never" 611 | ], 612 | "valid-typeof": [ 613 | 2, 614 | { 615 | "requireStringLiterals": false 616 | } 617 | ], 618 | "vars-on-top": 0, 619 | "wrap-iife": [ 620 | 2, 621 | "outside" 622 | ], 623 | "wrap-regex": 2, 624 | "yoda": [ 625 | 2, 626 | "never", 627 | { 628 | "exceptRange": false, 629 | "onlyEquality": false 630 | } 631 | ] 632 | }, 633 | "overrides": [ 634 | { 635 | "files": ["**/*.tsx", "**/*.ts"], 636 | "extends": "plugin:@typescript-eslint/recommended", 637 | "rules": { 638 | "@typescript-eslint/ban-ts-ignore": 0, 639 | "@typescript-eslint/ban-types": 1, 640 | "@typescript-eslint/ban-ts-comment": 0, 641 | "@typescript-eslint/no-var-requires": 0, 642 | "@typescript-eslint/prefer-interface": 0, 643 | "@typescript-eslint/explicit-function-return-type": 0, 644 | "@typescript-eslint/explicit-module-boundary-types": 0, 645 | "@typescript-eslint/indent": [ 646 | 2, 647 | 4, 648 | { 649 | "SwitchCase": 0 650 | } 651 | ], 652 | "@typescript-eslint/no-use-before-define": [ 653 | 2, 654 | { 655 | "classes": false, 656 | "functions": false, 657 | "variables": false 658 | } 659 | ], 660 | "react/jsx-filename-extension": [ 661 | 1, 662 | { 663 | "extensions": [".jsx", ".tsx"] 664 | } 665 | ] 666 | } 667 | } 668 | ] 669 | } 670 | --------------------------------------------------------------------------------