diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml deleted file mode 100644 index fe461b42..00000000 --- a/.github/workflows/dependency-review.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Dependency Review Action -# -# This Action will scan dependency manifest files that change as part of a Pull Request, surfacing known-vulnerable versions of the packages declared or updated in the PR. Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable packages will be blocked from merging. -# -# Source repository: https://github.com/actions/dependency-review-action -# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement -name: 'Dependency Review' -on: [pull_request] - -permissions: - contents: read - -jobs: - dependency-review: - runs-on: ubuntu-latest - steps: - - name: 'Checkout Repository' - uses: actions/checkout@v3 - - name: 'Dependency Review' - uses: actions/dependency-review-action@v2 diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml index 458985e2..1fbf0e36 100644 --- a/.github/workflows/pylint.yml +++ b/.github/workflows/pylint.yml @@ -1,9 +1,10 @@ -name: Pylint +name: Python Linting and Formatting on: [push] jobs: - build: + pylint: + name: Pylint runs-on: ubuntu-latest defaults: run: @@ -12,15 +13,61 @@ jobs: matrix: python-version: ["3.10"] steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - name: Analysing the code with pylint - run: | - pylint-ignore $(git ls-files '*.py') + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Analysing the code with pylint + run: | + pylint-ignore $(git ls-files '*.py') + + black: + name: Black + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./server + strategy: + matrix: + python-version: ["3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black + - name: Check code formatting with black + run: | + black --check $(git ls-files '*.py') + + isort: + name: isort + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./server + strategy: + matrix: + python-version: ["3.10"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install isort + - name: Check import sorting with isort + run: | + isort --check $(git ls-files '*.py') --profile=black \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 364aa76c..3b2f019d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -30,6 +30,7 @@ "@babel/core": "7.26.10", "@babel/eslint-parser": "7.27.0", "@babel/preset-env": "7.26.9", + "@types/vuelidate": "^0.7.22", "@vitejs/plugin-vue2": "2.3.3", "eslint": "8.57.0", "eslint-config-airbnb-base": "15.0.0", @@ -2666,6 +2667,52 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/vuelidate": { + "version": "0.7.22", + "resolved": "https://registry.npmjs.org/@types/vuelidate/-/vuelidate-0.7.22.tgz", + "integrity": "sha512-bD3pP9FgL3pxMVQ9NJ3d8BbV8Ij6xsrDKdCO4l1Wq/AksXxRRmQ9lmYjRJwn/hLMcgWO/k0QdULfZWpRz13adw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue": "^2.7.15" + } + }, + "node_modules/@types/vuelidate/node_modules/@vue/compiler-sfc": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.16.tgz", + "integrity": "sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.23.5", + "postcss": "^8.4.14", + "source-map": "^0.6.1" + }, + "optionalDependencies": { + "prettier": "^1.18.2 || ^2.0.0" + } + }, + "node_modules/@types/vuelidate/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/@types/vuelidate/node_modules/vue": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz", + "integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==", + "deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "2.7.16", + "csstype": "^3.1.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.1.tgz", @@ -7361,6 +7408,23 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", diff --git a/client/package.json b/client/package.json index e1ebe7ee..145d35bf 100644 --- a/client/package.json +++ b/client/package.json @@ -34,6 +34,7 @@ "@babel/core": "7.26.10", "@babel/eslint-parser": "7.27.0", "@babel/preset-env": "7.26.9", + "@types/vuelidate": "^0.7.22", "@vitejs/plugin-vue2": "2.3.3", "eslint": "8.57.0", "eslint-config-airbnb-base": "15.0.0", diff --git a/client/src/main.js b/client/src/main.js index 68ee773b..c4008699 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -64,6 +64,14 @@ Vue.filter('capitalize', (value) => { if (!value) return ''; return value.toString().split(' ').map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); }); +Vue.filter('uppercase', (value) => { + if (!value) return ''; + return value.toString().toUpperCase(); +}); +Vue.filter('lowercase', (value) => { + if (!value) return ''; + return value.toString().toLowerCase(); +}); new Vue({ router, diff --git a/client/src/store/modules/script.js b/client/src/store/modules/script.js index 0437be64..66772997 100644 --- a/client/src/store/modules/script.js +++ b/client/src/store/modules/script.js @@ -10,6 +10,7 @@ export default { script: {}, cues: {}, cuts: [], + stageDirectionStyles: [], }, mutations: { SET_REVISIONS(state, revisions) { @@ -27,6 +28,9 @@ export default { SET_CUTS(state, cuts) { state.cuts = cuts; }, + SET_STAGE_DIRECTION_STYLES(state, styles) { + state.stageDirectionStyles = styles; + }, }, actions: { async GET_SCRIPT_REVISIONS(context) { @@ -216,6 +220,68 @@ export default { Vue.$toast.error('Unable to save script cuts'); } }, + async GET_STAGE_DIRECTION_STYLES(context) { + const response = await fetch(`${makeURL('/api/v1/show/script/stage_direction_styles')}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.ok) { + const respJson = await response.json(); + context.commit('SET_STAGE_DIRECTION_STYLES', respJson.styles); + } else { + log.error('Unable to load stage direction styles'); + } + }, + async ADD_STAGE_DIRECTION_STYLE(context, style) { + const response = await fetch(`${makeURL('/api/v1/show/script/stage_direction_styles')}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(style), + }); + if (response.ok) { + context.dispatch('GET_STAGE_DIRECTION_STYLES'); + Vue.$toast.success('Added new stage direction style!'); + } else { + log.error('Unable to add new stage direction style'); + Vue.$toast.error('Unable to add new stage direction style'); + } + }, + async DELETE_STAGE_DIRECTION_STYLE(context, styleId) { + const response = await fetch(`${makeURL('/api/v1/show/script/stage_direction_styles')}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: styleId }), + }); + if (response.ok) { + context.dispatch('GET_STAGE_DIRECTION_STYLES'); + Vue.$toast.success('Deleted stage direction style!'); + } else { + log.error('Unable to delete stage direction style'); + Vue.$toast.error('Unable to delete stage direction style'); + } + }, + async UPDATE_STAGE_DIRECTION_STYLE(context, style) { + const response = await fetch(`${makeURL('/api/v1/show/script/stage_direction_styles')}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(style), + }); + if (response.ok) { + context.dispatch('GET_STAGE_DIRECTION_STYLES'); + Vue.$toast.success('Updated stage direction style!'); + } else { + log.error('Unable to edit stage direction style'); + Vue.$toast.error('Unable to edit stage direction style'); + } + }, }, getters: { SCRIPT_REVISIONS(state) { @@ -237,5 +303,8 @@ export default { SCRIPT_CUTS(state) { return state.cuts; }, + STAGE_DIRECTION_STYLES(state) { + return state.stageDirectionStyles; + }, }, }; diff --git a/client/src/views/show/ShowLiveView.vue b/client/src/views/show/ShowLiveView.vue index 370df0bc..eb1a9f0b 100644 --- a/client/src/views/show/ShowLiveView.vue +++ b/client/src/views/show/ShowLiveView.vue @@ -73,6 +73,7 @@ :cue-types="CUE_TYPES" :cues="getCuesForLine(line)" :cuts="SCRIPT_CUTS" + :stage-direction-styles="STAGE_DIRECTION_STYLES" @last-line-change="handleLastLineChange" @first-line-change="handleFirstLineChange" /> @@ -149,6 +150,7 @@ export default { await this.GET_CUE_TYPES(); await this.LOAD_CUES(); await this.GET_CUTS(); + await this.GET_STAGE_DIRECTION_STYLES(); await this.getMaxScriptPage(); this.updateElapsedTime(); @@ -415,7 +417,7 @@ export default { }, ...mapActions(['GET_SHOW_SESSION_DATA', 'LOAD_SCRIPT_PAGE', 'GET_ACT_LIST', 'GET_SCENE_LIST', 'GET_CHARACTER_LIST', 'GET_CHARACTER_GROUP_LIST', 'LOAD_CUES', 'GET_CUE_TYPES', - 'GET_CUTS']), + 'GET_CUTS', 'GET_STAGE_DIRECTION_STYLES']), }, computed: { pageIter() { @@ -440,7 +442,7 @@ export default { }, ...mapGetters(['CURRENT_SHOW_SESSION', 'GET_SCRIPT_PAGE', 'ACT_LIST', 'SCENE_LIST', 'CHARACTER_LIST', 'CHARACTER_GROUP_LIST', 'CURRENT_SHOW', 'CUE_TYPES', 'SCRIPT_CUES', - 'INTERNAL_UUID', 'SESSION_FOLLOW_DATA', 'SCRIPT_CUTS', 'SETTINGS']), + 'INTERNAL_UUID', 'SESSION_FOLLOW_DATA', 'SCRIPT_CUTS', 'SETTINGS', 'STAGE_DIRECTION_STYLES']), }, watch: { SESSION_FOLLOW_DATA() { diff --git a/client/src/views/show/config/ConfigScript.vue b/client/src/views/show/config/ConfigScript.vue index caafb43a..7923f2a5 100644 --- a/client/src/views/show/config/ConfigScript.vue +++ b/client/src/views/show/config/ConfigScript.vue @@ -82,6 +82,9 @@ + + + @@ -134,10 +137,11 @@ import { mapActions, mapGetters } from 'vuex'; import { required } from 'vuelidate/lib/validators'; import ScriptConfig from '@/vue_components/show/config/script/ScriptEditor.vue'; +import StageDirectionStyles from '@/vue_components/show/config/script/StageDirectionStyles.vue'; export default { name: 'ConfigScript', - components: { ScriptConfig }, + components: { ScriptConfig, StageDirectionConfigs: StageDirectionStyles }, data() { return { revisionColumns: [ diff --git a/client/src/vue_components/show/config/cues/CueEditor.vue b/client/src/vue_components/show/config/cues/CueEditor.vue index d55560f1..0fd49241 100644 --- a/client/src/vue_components/show/config/cues/CueEditor.vue +++ b/client/src/vue_components/show/config/cues/CueEditor.vue @@ -62,6 +62,7 @@ :cue-types="CUE_TYPES" :cues="getCuesForLine(line)" :line-part-cuts="SCRIPT_CUTS" + :stage-direction-styles="STAGE_DIRECTION_STYLES" /> @@ -185,6 +186,7 @@ export default { await this.GET_CUE_TYPES(); await this.LOAD_CUES(); await this.GET_CUTS(); + await this.GET_STAGE_DIRECTION_STYLES(); // Get the max page of the saved version of the script await this.getMaxScriptPage(); @@ -261,7 +263,7 @@ export default { ...mapActions(['GET_SCENE_LIST', 'GET_ACT_LIST', 'GET_CHARACTER_LIST', 'GET_CHARACTER_GROUP_LIST', 'LOAD_SCRIPT_PAGE', 'ADD_BLANK_PAGE', 'GET_SCRIPT_CONFIG_STATUS', 'RESET_TO_SAVED', 'SAVE_NEW_PAGE', 'SAVE_CHANGED_PAGE', 'GET_CUE_TYPES', 'LOAD_CUES', - 'GET_CUTS']), + 'GET_CUTS', 'GET_STAGE_DIRECTION_STYLES']), }, computed: { currentEditPageKey() { @@ -275,7 +277,8 @@ export default { }, ...mapGetters(['CURRENT_SHOW', 'ACT_LIST', 'SCENE_LIST', 'CHARACTER_LIST', 'CHARACTER_GROUP_LIST', 'CAN_REQUEST_EDIT', 'CURRENT_EDITOR', 'INTERNAL_UUID', - 'GET_SCRIPT_PAGE', 'DEBUG_MODE_ENABLED', 'CUE_TYPES', 'SCRIPT_CUES', 'SCRIPT_CUTS']), + 'GET_SCRIPT_PAGE', 'DEBUG_MODE_ENABLED', 'CUE_TYPES', 'SCRIPT_CUES', 'SCRIPT_CUTS', + 'STAGE_DIRECTION_STYLES']), }, watch: { currentEditPage(val) { diff --git a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue index 439e5550..aa45e420 100644 --- a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue +++ b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue @@ -44,9 +44,21 @@ > - {{ line.line_parts[0].line_text }} + + + @@ -234,6 +246,10 @@ export default { required: true, type: Array, }, + stageDirectionStyles: { + required: true, + type: Array, + }, }, data() { return { @@ -411,6 +427,33 @@ export default { sceneLabel() { return this.scenes.find((scene) => (scene.id === this.line.scene_id)).name; }, + stageDirectionStyle() { + const sdStyle = this.stageDirectionStyles.find( + (style) => (style.id === this.line.stage_direction_style_id), + ); + if (this.line.stage_direction) { + return sdStyle; + } + return null; + }, + stageDirectionStyling() { + if (this.line.stage_direction_style_id == null || this.stageDirectionStyle == null) { + return { + 'background-color': 'darkslateblue', + 'font-style': 'italic', + }; + } + const style = { + 'font-weight': this.stageDirectionStyle.bold ? 'bold' : 'normal', + 'font-style': this.stageDirectionStyle.italic ? 'italic' : 'normal', + 'text-decoration-line': this.stageDirectionStyle.underline ? 'underline' : 'none', + color: this.stageDirectionStyle.text_colour, + }; + if (this.stageDirectionStyle.enable_background_colour) { + style['background-color'] = this.stageDirectionStyle.background_colour; + } + return style; + }, }, }; diff --git a/client/src/vue_components/show/config/script/ScriptEditor.vue b/client/src/vue_components/show/config/script/ScriptEditor.vue index e8c59ced..8cf433d0 100644 --- a/client/src/vue_components/show/config/script/ScriptEditor.vue +++ b/client/src/vue_components/show/config/script/ScriptEditor.vue @@ -102,6 +102,7 @@ :previous-line-fn="getPreviousLineForIndex" :next-line-fn="getNextLineForIndex" :is-stage-direction="line.stage_direction" + :stage-direction-styles="STAGE_DIRECTION_STYLES" @input="lineChange(line, index)" @doneEditing="doneEditingLine(currentEditPage, index)" @deleteLine="deleteLine(currentEditPage, index)" @@ -120,6 +121,7 @@ :can-edit="canEdit" :line-part-cuts="linePartCuts" :insert-mode="insertMode" + :stage-direction-styles="STAGE_DIRECTION_STYLES" @editLine="beginEditingLine(currentEditPage, index)" @cutLinePart="cutLinePart" @insertLine="insertLineAt(currentEditPage, index)" @@ -300,6 +302,7 @@ export default { page: null, stage_direction: false, line_parts: [], + stage_direction_style_id: null, }, curSavePage: null, totalSavePages: null, @@ -352,6 +355,8 @@ export default { await this.GET_SCENE_LIST(); await this.GET_CHARACTER_LIST(); await this.GET_CHARACTER_GROUP_LIST(); + // Stage direction styles + await this.GET_STAGE_DIRECTION_STYLES(); // Handle script cuts await this.GET_CUTS(); this.resetCutsToSaved(); @@ -785,7 +790,7 @@ export default { 'SET_CUT_MODE', 'INSERT_BLANK_LINE', 'RESET_INSERTED']), ...mapActions(['GET_SCENE_LIST', 'GET_ACT_LIST', 'GET_CHARACTER_LIST', 'GET_CHARACTER_GROUP_LIST', 'LOAD_SCRIPT_PAGE', 'ADD_BLANK_PAGE', 'GET_SCRIPT_CONFIG_STATUS', - 'RESET_TO_SAVED', 'SAVE_NEW_PAGE', 'SAVE_CHANGED_PAGE', 'GET_CUTS', 'SAVE_SCRIPT_CUTS']), + 'RESET_TO_SAVED', 'SAVE_NEW_PAGE', 'SAVE_CHANGED_PAGE', 'GET_CUTS', 'SAVE_SCRIPT_CUTS', 'GET_STAGE_DIRECTION_STYLES']), }, computed: { canGenerateDebugScript() { @@ -826,7 +831,7 @@ export default { ...mapGetters(['CURRENT_SHOW', 'TMP_SCRIPT', 'ACT_LIST', 'SCENE_LIST', 'CHARACTER_LIST', 'CHARACTER_GROUP_LIST', 'CAN_REQUEST_EDIT', 'CURRENT_EDITOR', 'INTERNAL_UUID', 'GET_SCRIPT_PAGE', 'DEBUG_MODE_ENABLED', 'DELETED_LINES', 'SCENE_BY_ID', 'ACT_BY_ID', - 'IS_CUT_MODE', 'SCRIPT_CUTS', 'INSERTED_LINES']), + 'IS_CUT_MODE', 'SCRIPT_CUTS', 'INSERTED_LINES', 'STAGE_DIRECTION_STYLES']), }, watch: { currentEditPage(val) { diff --git a/client/src/vue_components/show/config/script/ScriptLineEditor.vue b/client/src/vue_components/show/config/script/ScriptLineEditor.vue index 76e81b60..5fb02b49 100644 --- a/client/src/vue_components/show/config/script/ScriptLineEditor.vue +++ b/client/src/vue_components/show/config/script/ScriptLineEditor.vue @@ -72,6 +72,19 @@ @input="stateChange" @addLinePart="addLinePart" /> + + + ({ value: style.id, text: style.description })), + ]; + }, }, }; diff --git a/client/src/vue_components/show/config/script/ScriptLineViewer.vue b/client/src/vue_components/show/config/script/ScriptLineViewer.vue index d8a71976..d5f2c6a1 100644 --- a/client/src/vue_components/show/config/script/ScriptLineViewer.vue +++ b/client/src/vue_components/show/config/script/ScriptLineViewer.vue @@ -73,9 +73,21 @@ > - {{ line.line_parts[0].line_text }} + + + @@ -146,6 +158,10 @@ export default { type: Boolean, default: false, }, + stageDirectionStyles: { + required: true, + type: Array, + }, }, computed: { needsHeadings() { @@ -196,6 +212,33 @@ export default { sceneLabel() { return this.scenes.find((scene) => (scene.id === this.line.scene_id)).name; }, + stageDirectionStyle() { + const sdStyle = this.stageDirectionStyles.find( + (style) => (style.id === this.line.stage_direction_style_id), + ); + if (this.line.stage_direction) { + return sdStyle; + } + return null; + }, + stageDirectionStyling() { + if (this.line.stage_direction_style_id == null || this.stageDirectionStyle == null) { + return { + 'background-color': 'darkslateblue', + 'font-style': 'italic', + }; + } + const style = { + 'font-weight': this.stageDirectionStyle.bold ? 'bold' : 'normal', + 'font-style': this.stageDirectionStyle.italic ? 'italic' : 'normal', + 'text-decoration-line': this.stageDirectionStyle.underline ? 'underline' : 'none', + color: this.stageDirectionStyle.text_colour, + }; + if (this.stageDirectionStyle.enable_background_colour) { + style['background-color'] = this.stageDirectionStyle.background_colour; + } + return style; + }, ...mapGetters(['IS_CUT_MODE']), }, methods: { diff --git a/client/src/vue_components/show/config/script/StageDirectionStyles.vue b/client/src/vue_components/show/config/script/StageDirectionStyles.vue new file mode 100644 index 00000000..8e9f7d53 --- /dev/null +++ b/client/src/vue_components/show/config/script/StageDirectionStyles.vue @@ -0,0 +1,566 @@ + + + diff --git a/client/src/vue_components/show/live/ScriptLineViewer.vue b/client/src/vue_components/show/live/ScriptLineViewer.vue index 52543bbc..d72974b3 100644 --- a/client/src/vue_components/show/live/ScriptLineViewer.vue +++ b/client/src/vue_components/show/live/ScriptLineViewer.vue @@ -44,8 +44,22 @@ > {{ line.line_parts[0].line_text }} + :style="stageDirectionStyling" + > + + + +