├── CONTRIBUTING.md ├── .editorconfig ├── .eslintrc.backend.js ├── .eslintrc.frontend.js ├── .eslintrc.js ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── npm-publish.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc.json ├── CODE_OF_CONDUCT.md ├── LICENSE.md ├── README.md ├── admin ├── custom.d.ts ├── src │ ├── components │ │ ├── Action │ │ │ ├── Action.tsx │ │ │ ├── ActionButtons │ │ │ │ ├── ActionButtons.tsx │ │ │ │ └── index.ts │ │ │ ├── ActionDateTimePicker │ │ │ │ ├── ActionDateTimePicker.tsx │ │ │ │ ├── ActionDateTimerPicker.css │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── ActionManager │ │ │ ├── ActionManager.tsx │ │ │ └── index.ts │ │ └── Initializer │ │ │ ├── Initializer.tsx │ │ │ └── index.ts │ ├── hooks │ │ ├── usePublisher.tsx │ │ └── useSettings.tsx │ ├── index.ts │ ├── pluginId.ts │ ├── translations │ │ ├── de.json │ │ ├── en.json │ │ ├── fr.json │ │ └── nl.json │ └── utils │ │ ├── getPluginEndpointURL.ts │ │ ├── getTrad.ts │ │ ├── prefixPluginTranslation.ts │ │ └── requestPluginEndpoint.ts ├── tsconfig.build.json └── tsconfig.json ├── assets ├── add.png ├── collection.png ├── default.png ├── edit-delete.png └── single.png ├── package.json ├── server ├── bootstrap.js ├── config │ ├── cron-tasks.js │ ├── index.js │ └── schema.js ├── content-types │ ├── action-content-type │ │ └── index.js │ └── index.js ├── controllers │ ├── action-controller.js │ ├── index.js │ └── settings-controller.js ├── index.js ├── middlewares │ ├── index.js │ └── validate-before-scheduling.js ├── register.js ├── routes │ ├── admin │ │ ├── action-routes.js │ │ ├── index.js │ │ └── settings-routes.js │ ├── content-api │ │ ├── action.js │ │ └── index.js │ └── index.js ├── services │ ├── action-service.js │ ├── emit-service.js │ ├── index.js │ ├── publication-service.js │ └── settings-service.js └── utils │ ├── getEntityUId.js │ ├── getPluginService.js │ ├── pluginId.js │ └── relations.js ├── strapi-admin.js ├── strapi-server.js └── yarn.lock / CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Strapi Publisher Plugin 2 | 3 | We appreciate your interest in contributing to the **Strapi Publisher Plugin**! 🚀 4 | This document outlines the guidelines for contributing to this project. 5 | 6 | ## 🏛 Community Guidelines 7 | We strive to maintain a welcoming and inclusive environment. Please be respectful and follow the [Contributor Covenant Code of Conduct](https://www.contributor-covenant.org/). 8 | 9 | --- 10 | 11 | ## 🔧 Development Workflow 12 | This plugin uses **Yalc** to test changes in a separate Strapi instance. Instead of a built-in playground, you'll need to link your plugin to a Strapi project using **Yalc**. 13 | 14 | ### 1️⃣ Fork the repository 15 | [Go to the repository](https://github.com/pluginpal/strapi-plugin-publisher) and fork it to your own GitHub account. 16 | 17 | ### 2️⃣ Clone your fork 18 | ```bash 19 | git clone git@github.com:YOUR_USERNAME/strapi-plugin-publisher.git 20 | ``` 21 | 22 | ### 3️⃣ Install dependencies 23 | Navigate into the project directory and install dependencies: 24 | ```bash 25 | cd strapi-plugin-publisher && yarn install 26 | ``` 27 | 28 | ### 4️⃣ Link the plugin using **Yalc** 29 | You'll need **Yalc** to test the plugin in a separate Strapi project. 30 | 31 | 1. **Install Yalc** globally (if you haven't already): 32 | ```bash 33 | npm i -g yalc 34 | ``` 35 | 36 | 2. **Publish the plugin to Yalc**: 37 | ```bash 38 | yalc publish 39 | ``` 40 | 41 | 3. **Go to your Strapi test project** (the project where you want to test the plugin): 42 | ```bash 43 | cd /path/to/your-strapi-project 44 | ``` 45 | 46 | 4. **Link the plugin in your Strapi test project**: 47 | ```bash 48 | yalc link strapi-plugin-publisher 49 | ``` 50 | 51 | ### 5️⃣ Watch for changes 52 | Go back to the **plugin's repository** and run: 53 | ```bash 54 | yarn run watch:link 55 | ``` 56 | This will automatically recompile changes and update the plugin inside your Strapi test project. 57 | 58 | ### 6️⃣ Start your Strapi test project 59 | In your **Strapi test project**, start the development environment: 60 | ```bash 61 | yarn develop 62 | ``` 63 | Now, you should see the **Publisher Plugin** available in your Strapi admin panel. 64 | 65 | --- 66 | 67 | ## ✅ Commit Message Convention 68 | We follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) format: 69 | - `fix`: Bug fixes (e.g., `fix: resolve issue with publish scheduling`) 70 | - `feat`: New features (e.g., `feat: add new logging system`) 71 | - `refactor`: Code improvements without functional changes 72 | - `docs`: Documentation updates 73 | - `chore`: Tooling or maintenance changes 74 | 75 | Example: 76 | ```bash 77 | git commit -m "feat: add support for multiple publish schedules" 78 | ``` 79 | 80 | --- 81 | 82 | ## 🔍 Linting 83 | We use [ESLint](https://eslint.org/) to enforce coding standards. 84 | 85 | ### 🛠 Useful Commands: 86 | | Command | Description | 87 | |---------|-------------| 88 | | `yarn lint` | Run ESLint to check for linting issues | 89 | | `yarn lint:fix` | Auto-fix ESLint issues | 90 | 91 | --- 92 | 93 | ## 📬 Submitting a Pull Request 94 | > **First time contributing?** Check out [this guide](https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github). 95 | 96 | ### 🔹 Guidelines: 97 | - Keep pull requests **small and focused on a single change**. 98 | - Ensure **ESLint passes** before submitting. 99 | - If updating the UI, include **screenshots**. 100 | - Follow the **Pull Request Template** when submitting a PR. 101 | - **Discuss major API changes** with maintainers by opening an issue first. 102 | 103 | --- 104 | 105 | ## 🐞 Reporting Bugs 106 | If you find a bug, please **create an issue** on GitHub: 107 | - **Clearly describe the problem** 108 | - Include **steps to reproduce** 109 | - Attach **screenshots/logs if possible** 110 | 111 | [Create a new issue](https://github.com/pluginpal/strapi-plugin-publisher/issues/new). 112 | 113 | --- 114 | 115 | ## 💡 Feature Requests 116 | If you have an idea for a **new feature**, open an issue and describe: 117 | - **Why is this feature needed?** 118 | - **How would it work?** 119 | 120 | Feature requests should **align with the plugin's goals**. 121 | 122 | --- 123 | 124 | Thank you for contributing! 🎉 125 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = false 6 | indent_style = tab 7 | indent_size = 2 8 | -------------------------------------------------------------------------------- /.eslintrc.backend.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | $schema: 'https://json.schemastore.org/eslintrc', 3 | env: { 4 | es6: true, 5 | node: true, 6 | }, 7 | parserOptions: { 8 | ecmaVersion: 2020, 9 | sourceType: 'module', 10 | }, 11 | extends: ['eslint:recommended', 'plugin:node/recommended', 'prettier'], 12 | rules: { 13 | 'node/no-unsupported-features/es-syntax': 'off', 14 | 'node/no-extraneous-require': [ 15 | 'error', 16 | { 17 | allowModules: ['yup', 'lodash', '@strapi/utils'], 18 | }, 19 | ], 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.eslintrc.frontend.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | $schema: 'https://json.schemastore.org/eslintrc', 3 | parser: '@babel/eslint-parser', 4 | env: { 5 | browser: true, 6 | es6: true, 7 | }, 8 | plugins: ['react'], 9 | extends: ['eslint:recommended', 'plugin:react/recommended', 'prettier'], 10 | parserOptions: { 11 | requireConfigFile: false, 12 | ecmaVersion: 2020, 13 | ecmaFeatures: { 14 | jsx: true, 15 | }, 16 | sourceType: 'module', 17 | babelOptions: { 18 | presets: ['@babel/preset-react'], 19 | }, 20 | }, 21 | settings: { 22 | react: { 23 | version: 'detect', 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const frontendESLint = require('./.eslintrc.frontend.js'); 2 | const backendESLint = require('./.eslintrc.backend.js'); 3 | 4 | module.exports = { 5 | $schema: 'https://json.schemastore.org/eslintrc', 6 | parserOptions: { 7 | ecmaVersion: 2020, 8 | }, 9 | rules: { 10 | indent: ['error', 'tab'], 11 | 'linebreak-style': ['error', 'unix'], 12 | quotes: ['error', 'single'], 13 | semi: ['error', 'always'], 14 | }, 15 | globals: { 16 | strapi: 'readonly', 17 | }, 18 | overrides: [ 19 | { 20 | files: ['server/**/*'], 21 | ...backendESLint, 22 | }, 23 | { 24 | files: ['admin/**/*'], 25 | ...frontendESLint, 26 | }, 27 | ], 28 | }; 29 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # From https://github.com/Danimoth/gitattributes/blob/master/Web.gitattributes 2 | 3 | # Handle line endings automatically for files detected as text 4 | # and leave all files detected as binary untouched. 5 | * text=auto 6 | 7 | # 8 | # The above will handle all files NOT found below 9 | # 10 | 11 | # 12 | ## These files are text and should be normalized (Convert crlf => lf) 13 | # 14 | 15 | # source code 16 | *.php text 17 | *.css text 18 | *.sass text 19 | *.scss text 20 | *.less text 21 | *.styl text 22 | *.js text eol=lf 23 | *.coffee text 24 | *.json text 25 | *.htm text 26 | *.html text 27 | *.xml text 28 | *.svg text 29 | *.txt text 30 | *.ini text 31 | *.inc text 32 | *.pl text 33 | *.rb text 34 | *.py text 35 | *.scm text 36 | *.sql text 37 | *.sh text 38 | *.bat text 39 | 40 | # templates 41 | *.ejs text 42 | *.hbt text 43 | *.jade text 44 | *.haml text 45 | *.hbs text 46 | *.dot text 47 | *.tmpl text 48 | *.phtml text 49 | 50 | # git config 51 | .gitattributes text 52 | .gitignore text 53 | .gitconfig text 54 | 55 | # code analysis config 56 | .jshintrc text 57 | .jscsrc text 58 | .jshintignore text 59 | .csslintrc text 60 | 61 | # misc config 62 | *.yaml text 63 | *.yml text 64 | .editorconfig text 65 | 66 | # build config 67 | *.npmignore text 68 | *.bowerrc text 69 | 70 | # Documentation 71 | *.md text 72 | LICENSE text 73 | AUTHORS text 74 | 75 | 76 | # 77 | ## These files are binary and should be left untouched 78 | # 79 | 80 | # (binary is a macro for -text -diff) 81 | *.png binary 82 | *.jpg binary 83 | *.jpeg binary 84 | *.gif binary 85 | *.ico binary 86 | *.mov binary 87 | *.mp4 binary 88 | *.mp3 binary 89 | *.flv binary 90 | *.fla binary 91 | *.swf binary 92 | *.gz binary 93 | *.zip binary 94 | *.7z binary 95 | *.ttf binary 96 | *.eot binary 97 | *.woff binary 98 | *.pyc binary 99 | *.pdf binary 100 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | ### System 27 | - Node.js version: 28 | - NPM version: 29 | - Strapi version: 30 | - Plugin version: 31 | - Database: 32 | - Operating system: 33 | 34 | Quick note: You can also upload a screenshot or a copy of your package.json 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/workflows/npm-publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | on: 3 | release: 4 | types: [published] 5 | jobs: 6 | publish: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | with: 12 | fetch-depth: 0 # We need the full history to determine the source branch 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v3 15 | with: 16 | always-auth: true 17 | node-version: 18 18 | cache: 'yarn' 19 | registry-url: 'https://registry.npmjs.org/' 20 | - name: Install dependencies 21 | run: yarn install --frozen-lockfile 22 | - name: Build the plugin 23 | run: yarn build 24 | - name: Get the release tag version 25 | id: get_version 26 | run: echo "VERSION=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_OUTPUT 27 | - name: Extract pre-release tag if any 28 | id: extract_tag 29 | run: | 30 | VERSION="${{ steps.get_version.outputs.VERSION }}" 31 | if [[ $VERSION == *-* ]]; then 32 | # Extract everything between hyphen and last period (or end of string) 33 | PRETAG=$(echo $VERSION | sed -E 's/.*-([^.]+).*/\1/') 34 | echo "IS_PRERELEASE=true" >> $GITHUB_OUTPUT 35 | echo "NPM_TAG=$PRETAG" >> $GITHUB_OUTPUT 36 | else 37 | echo "IS_PRERELEASE=false" >> $GITHUB_OUTPUT 38 | echo "NPM_TAG=latest" >> $GITHUB_OUTPUT 39 | fi 40 | - name: Get source branch 41 | id: get_branch 42 | run: | 43 | RELEASE_COMMIT=$(git rev-list -n 1 ${{ steps.get_version.outputs.VERSION }}) 44 | SOURCE_BRANCH=$(git branch -r --contains $RELEASE_COMMIT | grep -v HEAD | head -n 1 | sed 's/.*origin\///') 45 | echo "SOURCE_BRANCH=$SOURCE_BRANCH" >> $GITHUB_OUTPUT 46 | - name: Set package version 47 | run: yarn version --new-version "${{ steps.get_version.outputs.VERSION }}" --no-git-tag-version 48 | - name: Publish package 49 | run: yarn publish --access public --tag ${{ steps.extract_tag.outputs.NPM_TAG }} 50 | env: 51 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 52 | - name: Push version bump 53 | uses: stefanzweifel/git-auto-commit-action@v4 54 | with: 55 | commit_message: 'chore: Bump version to ${{ steps.get_version.outputs.VERSION }}' 56 | file_pattern: 'package.json' 57 | branch: ${{ steps.get_branch.outputs.SOURCE_BRANCH }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't check auto-generated stuff into git 2 | coverage 3 | node_modules 4 | stats.json 5 | .yalc/ 6 | dist 7 | 8 | # Cruft 9 | .DS_Store 10 | npm-debug.log 11 | .idea 12 | yalc.lock 13 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # npm 2 | npm-debug.log 3 | 4 | # git 5 | .git 6 | .gitattributes 7 | .gitignore 8 | 9 | # vscode 10 | .vscode 11 | 12 | # RC files 13 | .eslintrc.js 14 | .eslintrc.backend.js 15 | .eslintrc.frontend.js 16 | .prettierrc.json 17 | 18 | # config files 19 | .editorconfig 20 | 21 | # github 22 | .github 23 | 24 | # assets 25 | assets -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package-lock.json -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "semi": true, 6 | "singleQuote": true, 7 | "useTabs": true, 8 | "arrowParens": "always", 9 | "endOfLine": "lf", 10 | "printWidth": 100 11 | } 12 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement by sending an email to the maintainer as defined in the package.json. 63 | All complaints will be reviewed and investigated promptly and fairly. 64 | 65 | All community leaders are obligated to respect the privacy and security of the 66 | reporter of any incident. 67 | 68 | ## Enforcement Guidelines 69 | 70 | Community leaders will follow these Community Impact Guidelines in determining 71 | the consequences for any action they deem in violation of this Code of Conduct: 72 | 73 | ### 1. Correction 74 | 75 | **Community Impact**: Use of inappropriate language or other behavior deemed 76 | unprofessional or unwelcome in the community. 77 | 78 | **Consequence**: A private, written warning from community leaders, providing 79 | clarity around the nature of the violation and an explanation of why the 80 | behavior was inappropriate. A public apology may be requested. 81 | 82 | ### 2. Warning 83 | 84 | **Community Impact**: A violation through a single incident or series 85 | of actions. 86 | 87 | **Consequence**: A warning with consequences for continued behavior. No 88 | interaction with the people involved, including unsolicited interaction with 89 | those enforcing the Code of Conduct, for a specified period of time. This 90 | includes avoiding interactions in community spaces as well as external channels 91 | like social media. Violating these terms may lead to a temporary or 92 | permanent ban. 93 | 94 | ### 3. Temporary Ban 95 | 96 | **Community Impact**: A serious violation of community standards, including 97 | sustained inappropriate behavior. 98 | 99 | **Consequence**: A temporary ban from any sort of interaction or public 100 | communication with the community for a specified period of time. No public or 101 | private interaction with the people involved, including unsolicited interaction 102 | with those enforcing the Code of Conduct, is allowed during this period. 103 | Violating these terms may lead to a permanent ban. 104 | 105 | ### 4. Permanent Ban 106 | 107 | **Community Impact**: Demonstrating a pattern of violation of community 108 | standards, including sustained inappropriate behavior, harassment of an 109 | individual, or aggression toward or disparagement of classes of individuals. 110 | 111 | **Consequence**: A permanent ban from any sort of public interaction within 112 | the community. 113 | 114 | ## Attribution 115 | 116 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 117 | version 2.0, available at 118 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 119 | 120 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 121 | enforcement ladder](https://github.com/mozilla/diversity). 122 | 123 | [homepage]: https://www.contributor-covenant.org 124 | 125 | For answers to common questions about this code of conduct, see the FAQ at 126 | https://www.contributor-covenant.org/faq. Translations are available at 127 | https://www.contributor-covenant.org/translations. 128 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 @PluginPal 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # strapi-plugin-publisher 2 | 3 | A plugin for [Strapi](https://github.com/strapi/strapi) that provides the ability to easily schedule publishing and unpublishing of any content type. 4 | 5 | [![Downloads](https://img.shields.io/npm/dm/strapi-plugin-publisher?style=for-the-badge)](https://img.shields.io/npm/dm/strapi-plugin-publisher?style=for-the-badge) 6 | [![Install size](https://img.shields.io/npm/l/strapi-plugin-publisher?style=for-the-badge)](https://img.shields.io/npm/l/strapi-plugin-publisher?style=for-the-badge) 7 | [![Package version](https://img.shields.io/github/v/release/PluginPal/strapi-plugin-publisher?style=for-the-badge)](https://img.shields.io/github/v/release/PluginPal/strapi-plugin-publisher?style=for-the-badge) 8 | 9 | ## Requirements 10 | 11 | The installation requirements are the same as Strapi itself and can be found in the documentation on the [Quick Start](https://strapi.io/documentation/developer-docs/latest/getting-started/quick-start.html) page in the Prerequisites info card. 12 | 13 | ### Supported Strapi versions 14 | 15 | - Strapi ^4.x.x (use `strapi-plugin-publisher@^1`) 16 | - Strapi ^5.2.x (use `strapi-plugin-publisher@^2`) 17 | 18 | **NOTE**: While this plugin may work with the older Strapi versions, they are not supported, it is always recommended to use the latest version of Strapi. 19 | 20 | ## Installation 21 | 22 | ```sh 23 | npm install strapi-plugin-publisher 24 | ``` 25 | 26 | **or** 27 | 28 | ```sh 29 | yarn add strapi-plugin-publisher 30 | ``` 31 | 32 | ## Configuration 33 | 34 | ### Enable the plugin 35 | 36 | The plugin configuration is stored in a config file located at ./config/plugins.js. If this file doesn't exists, you will need to create it. 37 | 38 | 39 | A sample configuration 40 | 41 | ```javascript 42 | module.exports = ({ env }) => ({ 43 | // .. 44 | 'publisher': { 45 | enabled: true, 46 | config: { 47 | hooks: { 48 | beforePublish: async ({ strapi, uid, entity }) => { 49 | console.log('beforePublish'); 50 | }, 51 | afterPublish: async ({ strapi, uid, entity }) => { 52 | console.log('afterPublish'); 53 | }, 54 | beforeUnpublish: async ({ strapi, uid, entity }) => { 55 | console.log('beforeUnpublish'); 56 | }, 57 | afterUnpublish: async ({ strapi, uid, entity }) => { 58 | console.log('afterUnpublish'); 59 | }, 60 | }, 61 | }, 62 | }, 63 | // .. 64 | }); 65 | ``` 66 | 67 | ### The Complete Plugin Configuration Object 68 | 69 | | Property | Description | Type | Default | Required | 70 | |----------------------------------|----------------------------------------------------------------------------------|----------| ------- | -------- | 71 | | actions | Settings associated with any actions. | Object | {} | No | 72 | | actions.syncFrequency | The frequency to check for actions to run. It is a cron expression | String | '*/1 * * * *' | No | 73 | | components | Settings associated with any of the plugins components | Object | {} | No | 74 | | components.dateTimePicker | Settings associated with the DateTimePicker component used to set action times | Object | {} | No | 75 | | components.dateTimePicker.step | The step between the numbers displayed for the time section of the DateTimePicker | Number | 1 | No | 76 | | components.dateTimePicker.locale | Allows to enforce another locale to change the date layout | String | browser locale | No | 77 | | hooks.beforePublish | An async function that runs before a content type is published | Function | () => {} | No | 78 | | hooks.afterPublish | An async function that runs after a content type is published | Function | () => {} | No | 79 | | hooks.beforeUnpublish | An async function that runs before a content type is un-published | Function | () => {} | No | 80 | | hooks.afterUnpublish | An async function that runs after a content type is un-published | Function | () => {} | No | 81 | | contentTypes | A list of content type uids where the publish actions should be displayed | Array | All content types | No | 82 | 83 | ### Enable server cron 84 | 85 | The `cron.enabled` configuration option needs to be set to true in [Server Configuration](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/configurations/required/server.html#server-configuration) for the plugin to work. 86 | 87 | ## Usage 88 | 89 | Once the plugin has been installed, configured and enabled a `Publisher` section will be added to the `informations` section of the edit view for all content types (single + collection) that have `draftAndPublish` enabled. The `Publisher` section will provide the ability to schedule publishing and unpublishing of the content type. The content type publication status is checked every minute. 90 | 91 | > If the Publisher section does not appear in the admin after the plugin is enabled then a clean rebuild of the admin is required. This can be done by deleting the generated `.cache` and `build` folders and then re-running the `develop` command. 92 | 93 | ### Single Content Type 94 | 95 | ![Sample single content type publisher section](https://github.com/PluginPal/strapi-plugin-publisher/blob/master/assets/single.png?raw=true) 96 | 97 | ### Collection Content Type 98 | 99 | ![Sample collection content type publisher section](https://github.com/PluginPal/strapi-plugin-publisher/blob/master/assets/collection.png?raw=true) 100 | 101 | ### Adding a (un)publish date 102 | 103 | Navigate to the entity record that should be (un)published, under the `informations` section click the `Add a (un)publish date` button. Enter in the date and click save, the entity record will then be (un)published at the specified time. 104 | 105 | ![default](https://github.com/PluginPal/strapi-plugin-publisher/blob/master/assets/default.png?raw=true) 106 | 107 | ![Add a (un)publish date](https://github.com/PluginPal/strapi-plugin-publisher/blob/master/assets/add.png?raw=true) 108 | 109 | ### Editing a (un)publish date 110 | 111 | Navigate to the entity record that requires its date changed, under the `informations` section click the `Edit (un)publish date` button. Enter in the new date and click save. 112 | 113 | ![Edit a (un)publish date](https://github.com/PluginPal/strapi-plugin-publisher/blob/master/assets/edit-delete.png?raw=true) 114 | 115 | ### Deleting a (un)publish date 116 | 117 | Navigate to the entity record that contains the date that should be removed, under the `informations` section click the `Delete (un)publish date` button. 118 | 119 | ![Delete a (un)publish date](https://github.com/PluginPal/strapi-plugin-publisher/blob/master/assets/edit-delete.png?raw=true) 120 | 121 | ## Bugs 122 | 123 | If any bugs are found please report them as a [Github Issue](https://github.com/PluginPal/strapi-plugin-publisher/issues) 124 | -------------------------------------------------------------------------------- /admin/custom.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@strapi/design-system/*'; 2 | declare module '@strapi/design-system'; 3 | -------------------------------------------------------------------------------- /admin/src/components/Action/Action.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { 3 | useRBAC, 4 | } from '@strapi/strapi/admin'; 5 | import PropTypes from 'prop-types'; 6 | import ActionTimePicker from './ActionDateTimePicker'; 7 | import ActionButtons from './ActionButtons/ActionButtons'; 8 | import { usePublisher } from '../../hooks/usePublisher'; 9 | import { Flex } from '@strapi/design-system'; 10 | 11 | const Action = ({ mode, documentId, entitySlug, locale }) => { 12 | const { createAction, getAction, updateAction, deleteAction } = usePublisher(); 13 | // State management 14 | const [actionId, setActionId] = useState(0); 15 | const [isEditing, setIsEditing] = useState(false); 16 | const [executeAt, setExecuteAt] = useState(null); 17 | const [isCreating, setIsCreating] = useState(false); 18 | const [isLoading, setIsLoading] = useState(false); 19 | const [canPublish, setCanPublish] = useState(true); 20 | 21 | // Fetch RBAC permissions 22 | const { isLoading: isLoadingPermissions, allowedActions } = useRBAC({ 23 | publish: [{ action: 'plugin::content-manager.explorer.publish', subject: entitySlug }], 24 | }); 25 | 26 | useEffect(() => { 27 | if (!isLoadingPermissions) { 28 | setCanPublish(allowedActions.canPublish); 29 | } 30 | }, [isLoadingPermissions]); 31 | 32 | const { 33 | isLoading: isLoadingAction, 34 | data, 35 | isRefetching: isRefetchingAction, 36 | } = getAction({ 37 | mode, 38 | entityId: documentId, 39 | entitySlug, 40 | }); 41 | 42 | // Update state based on fetched action data 43 | useEffect(() => { 44 | setIsLoading(true); 45 | if (!isLoadingAction && !isRefetchingAction) { 46 | setIsLoading(false); 47 | if (data) { 48 | setActionId(data.documentId); 49 | setExecuteAt(data.executeAt); 50 | setIsEditing(true); 51 | } else { 52 | setActionId(0); 53 | } 54 | } 55 | }, [isLoadingAction, isRefetchingAction]); 56 | 57 | // Handlers 58 | function handleDateChange(date) { 59 | setExecuteAt(date); 60 | //setExecuteAt(date.toISOString()); 61 | } 62 | 63 | const handleOnEdit = () => { 64 | setIsCreating(true); 65 | setIsEditing(false); 66 | }; 67 | 68 | const handleOnCreate = () => { 69 | setIsCreating(true); 70 | }; 71 | 72 | const handleOnSave = async () => { 73 | setIsLoading(true); 74 | // Create of update actie 75 | try { 76 | if (!actionId) { 77 | const { data: response } = await createAction({ 78 | mode, 79 | entityId: documentId, 80 | entitySlug, 81 | executeAt, 82 | locale, 83 | }); 84 | if (response.data && response.data.id) { 85 | setActionId(response.data.documentId); 86 | } 87 | } else { 88 | await updateAction({ id: actionId, body: { executeAt } }); 89 | } 90 | setIsCreating(false); 91 | setIsEditing(true); 92 | setIsLoading(false); 93 | } catch (error) { 94 | console.error('Error saving action:', error); 95 | setIsLoading(false); 96 | } 97 | }; 98 | 99 | const handleOnDelete = async () => { 100 | setIsLoading(true); 101 | await deleteAction({ id: actionId, mode: mode }); 102 | setActionId(0); 103 | setExecuteAt(null); 104 | setIsCreating(false); 105 | setIsEditing(false); 106 | setIsLoading(false); 107 | }; 108 | 109 | // Render 110 | return ( 111 | 112 | 119 | 131 | 132 | ); 133 | }; 134 | 135 | Action.propTypes = { 136 | mode: PropTypes.string.isRequired, 137 | documentId: PropTypes.string.isRequired, 138 | entitySlug: PropTypes.string.isRequired, 139 | locale: PropTypes.string, 140 | }; 141 | 142 | export default Action; 143 | -------------------------------------------------------------------------------- /admin/src/components/Action/ActionButtons/ActionButtons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useIntl } from 'react-intl'; 3 | import PropTypes from 'prop-types'; 4 | import { Button } from '@strapi/design-system'; 5 | import { Check, Cross, PaperPlane, Pencil, Trash } from '@strapi/icons'; 6 | import { getTrad } from '../../../utils/getTrad'; 7 | 8 | const ActionButtons = ({ 9 | mode, 10 | isEditing, 11 | onEdit, 12 | onCreate, 13 | isCreating, 14 | executeAt, 15 | onDelete, 16 | onSave, 17 | canPublish, 18 | isLoading, 19 | }) => { 20 | const { formatMessage } = useIntl(); 21 | 22 | function handleEditChange() { 23 | if (onEdit) { 24 | onEdit(); 25 | } 26 | } 27 | 28 | function handleCreateChange() { 29 | if (onCreate) { 30 | onCreate(); 31 | } 32 | } 33 | 34 | function handleSaveChange() { 35 | if (onSave) { 36 | onSave(); 37 | } 38 | } 39 | 40 | function handleDeleteChange() { 41 | if (onDelete) { 42 | onDelete(); 43 | } 44 | } 45 | 46 | // do not show any mutate buttons if user cannot publish 47 | if ((isCreating || isEditing) && !canPublish) { 48 | return null; 49 | } 50 | 51 | if (isCreating) { 52 | return ( 53 | 66 | ); 67 | } 68 | 69 | if (isEditing) { 70 | return ( 71 | <> 72 | 83 | 94 | 95 | ); 96 | } 97 | 98 | return ( 99 | 112 | ); 113 | }; 114 | 115 | ActionButtons.propTypes = { 116 | mode: PropTypes.string.isRequired, 117 | executeAt: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), 118 | isEditing: PropTypes.bool.isRequired, 119 | onEdit: PropTypes.func, 120 | onCreate: PropTypes.func, 121 | isCreating: PropTypes.bool.isRequired, 122 | isLoading: PropTypes.bool.isRequired, 123 | onDelete: PropTypes.func, 124 | onSave: PropTypes.func, 125 | canPublish: PropTypes.bool.isRequired, 126 | }; 127 | 128 | export default ActionButtons; 129 | -------------------------------------------------------------------------------- /admin/src/components/Action/ActionButtons/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ActionButtons'; 2 | -------------------------------------------------------------------------------- /admin/src/components/Action/ActionDateTimePicker/ActionDateTimePicker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { useIntl } from 'react-intl'; 4 | import { DateTimePicker, Typography } from '@strapi/design-system'; 5 | import { getTrad } from '../../../utils/getTrad'; 6 | import { useSettings } from '../../../hooks/useSettings'; 7 | 8 | import './ActionDateTimerPicker.css'; 9 | 10 | const ActionDateTimePicker = ({ executeAt, mode, isCreating, isEditing, onChange }) => { 11 | const { formatMessage, locale: browserLocale } = useIntl(); 12 | const [locale, setLocale] = useState(browserLocale); 13 | const [step, setStep] = useState(1); 14 | const { getSettings } = useSettings(); 15 | 16 | function handleDateChange(date) { 17 | if (onChange) { 18 | onChange(date); 19 | } 20 | } 21 | 22 | const { isLoading, data, isRefetching } = getSettings(); 23 | 24 | useEffect(() => { 25 | if (!isLoading && !isRefetching) { 26 | if (data) { 27 | setStep(data.components.dateTimePicker.step); 28 | const customLocale = data.components.dateTimePicker.locale; 29 | try { 30 | // Validate the locale using Intl.DateTimeFormat 31 | new Intl.DateTimeFormat(customLocale); 32 | setLocale(customLocale); // Set the custom locale if valid 33 | } catch (error) { 34 | console.warn( 35 | `'${customLocale}' is not a valid locale format. Falling back to browser locale: '${browserLocale}'`, 36 | ); 37 | setLocale(browserLocale); 38 | } 39 | } 40 | } 41 | }, [isLoading, isRefetching]); 42 | 43 | if (! isCreating && ! isEditing) { 44 | return null; 45 | } 46 | 47 | return ( 48 | <> 49 |
50 | 51 | {formatMessage({ 52 | id: getTrad(`action.header.${mode}.title`), 53 | defaultMessage: `${mode} Date`, 54 | })} 55 | 56 | 57 | 65 |
66 | {/* TODO remove styling when this issue is fixed: https://github.com/strapi/design-system/issues/1853 */} 67 | 80 | 81 | ); 82 | }; 83 | 84 | ActionDateTimePicker.propTypes = { 85 | executeAt: PropTypes.oneOfType([PropTypes.string, PropTypes.instanceOf(Date)]), 86 | onChange: PropTypes.func, 87 | mode: PropTypes.string.isRequired, 88 | isCreating: PropTypes.bool.isRequired, 89 | isEditing: PropTypes.bool.isRequired, 90 | }; 91 | 92 | export default ActionDateTimePicker; 93 | -------------------------------------------------------------------------------- /admin/src/components/Action/ActionDateTimePicker/ActionDateTimerPicker.css: -------------------------------------------------------------------------------- 1 | @media screen and (max-width: 1750px) { 2 | #action-date-time-picker fieldset legend + div { 3 | flex-wrap: wrap; 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /admin/src/components/Action/ActionDateTimePicker/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ActionDateTimePicker'; 2 | -------------------------------------------------------------------------------- /admin/src/components/Action/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Action'; 2 | -------------------------------------------------------------------------------- /admin/src/components/ActionManager/ActionManager.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useLocation } from 'react-router-dom'; 3 | import { useIntl } from 'react-intl'; 4 | import { Box, Typography, Divider } from '@strapi/design-system'; 5 | import Action from '../Action'; 6 | import { getTrad } from '../../utils/getTrad'; 7 | import { useSettings } from '../../hooks/useSettings'; 8 | import { 9 | unstable_useDocument as useDocument, 10 | unstable_useContentManagerContext as useContentManagerContext, 11 | } from '@strapi/strapi/admin'; 12 | 13 | const actionModes = ['publish', 'unpublish']; 14 | 15 | const ActionManagerComponent = ({ document, entity }) => { 16 | const { formatMessage } = useIntl(); 17 | const [showActions, setShowActions] = useState(false); 18 | const { getSettings } = useSettings(); 19 | const { isLoading, data, isRefetching } = getSettings(); 20 | 21 | const location = useLocation(); 22 | const params = new URLSearchParams(location.search); 23 | const currentLocale = params.get('plugins[i18n][locale]'); 24 | 25 | useEffect(() => { 26 | if (!isLoading && !isRefetching) { 27 | if (!data.contentTypes?.length || data.contentTypes?.find((uid) => uid === entity.slug)) { 28 | setShowActions(true); 29 | } 30 | } 31 | }, [isLoading, isRefetching]); 32 | 33 | if (!showActions) { 34 | return null; 35 | } 36 | 37 | const localizedEntry = [document, ...(document.localizations || [])].find( 38 | (entry) => entry.locale === currentLocale 39 | ); 40 | 41 | if (!localizedEntry) return null; 42 | 43 | return ( 44 | <> 45 | 46 | 47 | 48 | 49 | {formatMessage({ 50 | id: getTrad('plugin.name'), 51 | defaultMessage: 'Publisher', 52 | })} 53 | 54 | 55 | 56 | 57 | {actionModes.map((mode, index) => ( 58 |
59 | 66 |
67 | ))} 68 | 75 | 76 | ); 77 | }; 78 | 79 | const ActionManager = () => { 80 | const entity = useContentManagerContext(); 81 | const { document } = useDocument({ 82 | documentId: entity?.id, 83 | model: entity?.model, 84 | collectionType: entity?.collectionType, 85 | }); 86 | 87 | if (! entity.hasDraftAndPublish || entity.isCreatingEntry) { 88 | return null; 89 | } 90 | 91 | if (! document || ! entity) { 92 | return null; 93 | } 94 | 95 | return ; 96 | }; 97 | 98 | export default ActionManager; 99 | -------------------------------------------------------------------------------- /admin/src/components/ActionManager/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './ActionManager'; 2 | -------------------------------------------------------------------------------- /admin/src/components/Initializer/Initializer.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * Initializer 4 | * 5 | */ 6 | 7 | import { useEffect, useRef } from 'react'; 8 | import PropTypes from 'prop-types'; 9 | import { pluginId } from '../../pluginId'; 10 | 11 | //@ts-ignore 12 | const Initializer = ({ setPlugin }) => { 13 | const ref = useRef(); 14 | ref.current = setPlugin; 15 | 16 | useEffect(() => { 17 | //@ts-ignore 18 | ref.current(pluginId); 19 | }, []); 20 | 21 | return null; 22 | }; 23 | 24 | Initializer.propTypes = { 25 | setPlugin: PropTypes.func.isRequired, 26 | }; 27 | 28 | export default Initializer; 29 | -------------------------------------------------------------------------------- /admin/src/components/Initializer/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Initializer'; 2 | -------------------------------------------------------------------------------- /admin/src/hooks/usePublisher.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery, useMutation, useQueryClient } from 'react-query'; 2 | import { 3 | useFetchClient, 4 | useNotification, 5 | useForm, 6 | useAPIErrorHandler, 7 | } from '@strapi/strapi/admin'; 8 | import { useIntl } from 'react-intl'; 9 | import { pluginId } from '../pluginId'; 10 | import { getTrad } from '../utils/getTrad'; 11 | 12 | const buildQueryKey = (args) => args.filter((a) => a); 13 | 14 | export const usePublisher = () => { 15 | const { toggleNotification } = useNotification(); 16 | const setErrors = useForm('PublishAction', (state) => state.setErrors); 17 | const { _unstableFormatValidationErrors: formatValidationErrors } = useAPIErrorHandler(); 18 | const { del, post, put, get } = useFetchClient(); 19 | const queryClient = useQueryClient(); 20 | const { formatMessage } = useIntl(); 21 | 22 | function onSuccessHandler({ queryKey, notification }) { 23 | queryClient.invalidateQueries(queryKey); 24 | toggleNotification({ 25 | type: notification.type, 26 | message: formatMessage({ 27 | id: getTrad(notification.tradId), 28 | defaultMessage: 'Action completed successfully', 29 | }), 30 | }); 31 | } 32 | 33 | function onErrorHandler(error) { 34 | toggleNotification({ 35 | type: 'danger', 36 | message: 37 | error.response?.data?.error?.message || 38 | error.message || 39 | formatMessage({ 40 | id: 'notification.error', 41 | defaultMessage: 'An unexpected error occurred', 42 | }), 43 | }); 44 | 45 | if ( 46 | error.response?.data?.error?.name === 'ValidationError' 47 | ) { 48 | setErrors(formatValidationErrors(error.response?.data?.error)); 49 | } 50 | } 51 | 52 | function getAction(filters = {}) { 53 | return useQuery({ 54 | queryKey: buildQueryKey([ 55 | pluginId, 56 | 'entity-action', 57 | filters.documentId, 58 | filters.entitySlug, 59 | filters.mode, 60 | ]), 61 | queryFn: function () { 62 | return get(`/${pluginId}/actions`, { 63 | params: { filters }, 64 | }); 65 | }, 66 | select: function ({ data }) { 67 | return data.data[0] || false; 68 | }, 69 | }); 70 | } 71 | 72 | const { mutateAsync: createAction } = useMutation({ 73 | mutationFn: function (body) { 74 | return post(`/${pluginId}/actions`, { data: body }); 75 | }, 76 | onSuccess: ({ data: response }) => { 77 | const { data } = response; 78 | const queryKey = buildQueryKey([ 79 | pluginId, 80 | 'entity-action', 81 | data.documentId, 82 | data.entitySlug, 83 | data.mode, 84 | ]); 85 | onSuccessHandler({ 86 | queryKey, 87 | notification: { 88 | type: 'success', 89 | tradId: `action.notification.${data.mode}.create.success`, 90 | }, 91 | }); 92 | }, 93 | onError: onErrorHandler, 94 | }); 95 | 96 | const { mutateAsync: updateAction } = useMutation({ 97 | mutationFn: function ({ id, body }) { 98 | return put(`/${pluginId}/actions/${id}`, { data: body }); 99 | }, 100 | onSuccess: ({ data: response }) => { 101 | const { data } = response; 102 | const queryKey = buildQueryKey([ 103 | pluginId, 104 | 'entity-action', 105 | data.documentId, 106 | data.entitySlug, 107 | data.mode, 108 | ]); 109 | onSuccessHandler({ 110 | queryKey, 111 | notification: { 112 | type: 'success', 113 | tradId: `action.notification.${data.mode}.update.success`, 114 | }, 115 | }); 116 | }, 117 | onError: onErrorHandler, 118 | }); 119 | 120 | const { mutateAsync: deleteAction } = useMutation({ 121 | mutationFn: function ({ id }) { 122 | return del(`/${pluginId}/actions/${id}`); 123 | }, 124 | onSuccess: (_response, actionMode) => { 125 | const { mode } = actionMode; 126 | const queryKey = buildQueryKey([ 127 | pluginId, 128 | 'entity-action', 129 | ]); 130 | onSuccessHandler({ 131 | queryKey, 132 | notification: { 133 | type: 'success', 134 | tradId: `action.notification.${mode}.delete.success`, 135 | }, 136 | }); 137 | }, 138 | onError: onErrorHandler, 139 | }); 140 | 141 | return { getAction, createAction, updateAction, deleteAction }; 142 | }; 143 | -------------------------------------------------------------------------------- /admin/src/hooks/useSettings.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | import { useFetchClient } from '@strapi/strapi/admin'; 3 | 4 | import { pluginId } from '../pluginId'; 5 | 6 | export const useSettings = () => { 7 | const { get } = useFetchClient(); 8 | 9 | function getSettings() { 10 | return useQuery({ 11 | queryKey: [pluginId, 'settings'], 12 | queryFn: function () { 13 | return get(`/${pluginId}/settings`); 14 | }, 15 | select: function ({ data }) { 16 | return data.data || false; 17 | }, 18 | }); 19 | } 20 | 21 | return { 22 | getSettings, 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /admin/src/index.ts: -------------------------------------------------------------------------------- 1 | import pluginPkg from '../../package.json'; 2 | import { pluginId } from './pluginId'; 3 | import Initializer from './components/Initializer'; 4 | import ActionManager from './components/ActionManager'; 5 | import { prefixPluginTranslations } from './utils/prefixPluginTranslation'; 6 | 7 | const name = pluginPkg.strapi.name; 8 | 9 | export default { 10 | register(app: any) { 11 | app.registerPlugin({ 12 | id: pluginId, 13 | initializer: Initializer, 14 | isReady: false, 15 | name, 16 | }); 17 | }, 18 | 19 | bootstrap(app: any) { 20 | app.getPlugin('content-manager').injectComponent( 21 | 'editView', 22 | 'right-links', 23 | { name: 'action-manager', Component: ActionManager }, 24 | ); 25 | }, 26 | 27 | async registerTrads(app: any) { 28 | const { locales } = app; 29 | 30 | const importedTrads = await Promise.all( 31 | (locales as any[]).map((locale) => { 32 | return import(`./translations/${locale}.json`) 33 | .then(({ default: data }) => { 34 | return { 35 | data: prefixPluginTranslations(data, pluginId), 36 | locale, 37 | }; 38 | }) 39 | .catch(() => { 40 | return { 41 | data: {}, 42 | locale, 43 | }; 44 | }); 45 | }), 46 | ); 47 | 48 | return Promise.resolve(importedTrads); 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /admin/src/pluginId.ts: -------------------------------------------------------------------------------- 1 | import pluginPkg from '../../package.json'; 2 | 3 | export const pluginId = pluginPkg.strapi.name; 4 | -------------------------------------------------------------------------------- /admin/src/translations/de.json: -------------------------------------------------------------------------------- 1 | { 2 | "action.header.publish.title": "Veröffentlichung", 3 | "action.header.unpublish.title": "Ende der Veröffentlichung", 4 | "action.footer.publish.button.add": "Veröffentlichung planen", 5 | "action.footer.publish.button.edit": "Bearbeiten", 6 | "action.footer.publish.button.delete": "Löschen", 7 | "action.footer.publish.button.save": "Speichern", 8 | "action.footer.unpublish.button.add": "Veröffentlichung beenden", 9 | "action.footer.unpublish.button.edit": "Bearbeiten", 10 | "action.footer.unpublish.button.delete": "Löschen", 11 | "action.footer.unpublish.button.save": "Speichern", 12 | "action.notification.delete.action.error": "Löschen fehlgeschlagen", 13 | "action.notification.publish.create.success": "Veröffentlichung erfolgreich geplant", 14 | "action.notification.publish.delete.success": "Veröffentlichung erfolgreich entfernt", 15 | "action.notification.publish.update.success": "Veröffentlichung erfolgreich aktualisiert", 16 | "action.notification.publish.validation.error": "Zum Planen der Veröffentlichung müssen alle Pflichtfelder gespeichert sein", 17 | "action.notification.unpublish.create.success": "Veröffentlichungsende erfolgreich geplant", 18 | "action.notification.unpublish.delete.success": "Veröffentlichungsende erfolgreich entfernt", 19 | "action.notification.unpublish.update.success": "Veröffentlichungsende erfolgreich aktualisiert", 20 | "plugin.name": "Publisher" 21 | } 22 | -------------------------------------------------------------------------------- /admin/src/translations/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "action.header.publish.title": "publish date", 3 | "action.header.unpublish.title": "unpublish date", 4 | "action.footer.publish.button.add": "Publish date", 5 | "action.footer.publish.button.edit": "Edit", 6 | "action.footer.publish.button.delete": "Delete", 7 | "action.footer.publish.button.save": "Save", 8 | "action.footer.unpublish.button.add": "Unpublish date", 9 | "action.footer.unpublish.button.edit": "Edit", 10 | "action.footer.unpublish.button.delete": "Delete", 11 | "action.footer.unpublish.button.save": "Save", 12 | "action.notification.delete.action.error": "Delete action failed", 13 | "action.notification.publish.create.success": "Publish action created successfully", 14 | "action.notification.publish.delete.success": "Publish action deleted successfully", 15 | "action.notification.publish.update.success": "Publish action updated successfully", 16 | "action.notification.publish.validation.error": "Required fields must be saved before a publish date can be set", 17 | "action.notification.unpublish.create.success": "Unpublish action created successfully", 18 | "action.notification.unpublish.delete.success": "Unpublish action deleted successfully", 19 | "action.notification.unpublish.update.success": "Unpublish action updated successfully", 20 | "plugin.name": "Publisher" 21 | } 22 | -------------------------------------------------------------------------------- /admin/src/translations/fr.json: -------------------------------------------------------------------------------- 1 | { 2 | "action.header.publish.title": "date de publication", 3 | "action.header.unpublish.title": "date de dépublication", 4 | "action.footer.publish.button.add": "Ajouter une date de publication", 5 | "action.footer.publish.button.edit": "Changer la date de publication", 6 | "action.footer.publish.button.delete": "Supprimer la date de publication", 7 | "action.footer.publish.button.save": "Sauvegarder la date de publication", 8 | "action.footer.unpublish.button.add": "Ajouter une date de dépublication", 9 | "action.footer.unpublish.button.edit": "Changer la date de dépublication", 10 | "action.footer.unpublish.button.delete": "Supprimer la date de dépublication", 11 | "action.footer.unpublish.button.save": "Sauvegarder la date de dépublication", 12 | "action.notification.publish.create.success": "Action de publication créée avec succès", 13 | "action.notification.publish.delete.success": "Action de publication supprimée avec succès", 14 | "action.notification.publish.update.success": "L'action de publication a bien été mise à jour", 15 | "action.notification.unpublish.create.success": "Action de dépublication créée avec succès", 16 | "action.notification.unpublish.delete.success": "Action de dépublication supprimée avec succès", 17 | "action.notification.unpublish.update.success": "Action de dépublication mise à jour avec succès", 18 | "plugin.name": "Editeur" 19 | } 20 | -------------------------------------------------------------------------------- /admin/src/translations/nl.json: -------------------------------------------------------------------------------- 1 | { 2 | "action.header.publish.title": "Publiceren", 3 | "action.header.unpublish.title": "Depubliceren", 4 | "action.footer.publish.button.add": "Publiceer", 5 | "action.footer.publish.button.edit": "Bewerk", 6 | "action.footer.publish.button.delete": "Verwijder", 7 | "action.footer.publish.button.save": "Opslaan", 8 | "action.footer.unpublish.button.add": "Depubliceer", 9 | "action.footer.unpublish.button.edit": "Bewerk", 10 | "action.footer.unpublish.button.delete": "Verwijder", 11 | "action.footer.unpublish.button.save": "Opslaan", 12 | "action.notification.publish.create.success": "Gepubliceerd", 13 | "action.notification.publish.delete.success": "Publicatie verwijderd", 14 | "action.notification.publish.update.success": "Publicatie aangepast", 15 | "action.notification.unpublish.create.success": "Gedepubliceerd", 16 | "action.notification.unpublish.delete.success": "Depublicatie verwijderd", 17 | "action.notification.unpublish.update.success": "Depublicatie aangepast", 18 | "plugin.name": "Publisher" 19 | } 20 | -------------------------------------------------------------------------------- /admin/src/utils/getPluginEndpointURL.ts: -------------------------------------------------------------------------------- 1 | import { pluginId } from '../pluginId'; 2 | 3 | /** 4 | * Auto prefix URLs with the plugin id 5 | * 6 | * @param {String} endpoint plugin specific endpoint 7 | * @returns {String} plugin id prefixed endpoint 8 | */ 9 | export const getPluginEndpointURL = (endpoint: any) => `/${pluginId}/${endpoint}`; 10 | -------------------------------------------------------------------------------- /admin/src/utils/getTrad.ts: -------------------------------------------------------------------------------- 1 | import { pluginId } from '../pluginId'; 2 | 3 | export const getTrad = (id: any) => `${pluginId}.${id}`; 4 | -------------------------------------------------------------------------------- /admin/src/utils/prefixPluginTranslation.ts: -------------------------------------------------------------------------------- 1 | type TradOptions = Record; 2 | 3 | const prefixPluginTranslations = (trad: TradOptions, pluginId: string): TradOptions => { 4 | if (!pluginId) { 5 | throw new TypeError("pluginId can't be empty"); 6 | } 7 | return Object.keys(trad).reduce((acc, current) => { 8 | acc[`${pluginId}.${current}`] = trad[current]; 9 | return acc; 10 | }, {} as TradOptions); 11 | }; 12 | 13 | export { prefixPluginTranslations }; 14 | -------------------------------------------------------------------------------- /admin/src/utils/requestPluginEndpoint.ts: -------------------------------------------------------------------------------- 1 | import { useFetchClient } from '@strapi/strapi/admin'; 2 | import { getPluginEndpointURL } from './getPluginEndpointURL'; 3 | 4 | // @ts-ignore 5 | export const requestPluginEndpoint = async (endpoint: any, options = {}) => { 6 | const { get, post, put, del } = useFetchClient(); 7 | const url = getPluginEndpointURL(endpoint); 8 | 9 | // @ts-ignore 10 | switch (options.method?.toUpperCase()) { 11 | case 'POST': 12 | return post(url, options); 13 | case 'PUT': 14 | return put(url, options); 15 | case 'DELETE': 16 | return del(url, options); 17 | case 'GET': 18 | default: 19 | return get(url, options); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /admin/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "include": ["./src", "./custom.d.ts"], 4 | "exclude": ["**/*.test.ts", "**/*.test.tsx"], 5 | "compilerOptions": { 6 | "rootDir": "../", 7 | "baseUrl": ".", 8 | "outDir": "./dist" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /admin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@strapi/typescript-utils/tsconfigs/admin", 3 | "include": ["./src", "./custom.d.ts"], 4 | "compilerOptions": { 5 | "rootDir": "../", 6 | "baseUrl": "." 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /assets/add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluginpal/strapi-plugin-publisher/151ab04358db49902290d3a038ebcb0cd3448290/assets/add.png -------------------------------------------------------------------------------- /assets/collection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluginpal/strapi-plugin-publisher/151ab04358db49902290d3a038ebcb0cd3448290/assets/collection.png -------------------------------------------------------------------------------- /assets/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluginpal/strapi-plugin-publisher/151ab04358db49902290d3a038ebcb0cd3448290/assets/default.png -------------------------------------------------------------------------------- /assets/edit-delete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluginpal/strapi-plugin-publisher/151ab04358db49902290d3a038ebcb0cd3448290/assets/edit-delete.png -------------------------------------------------------------------------------- /assets/single.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pluginpal/strapi-plugin-publisher/151ab04358db49902290d3a038ebcb0cd3448290/assets/single.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/package", 3 | "name": "strapi-plugin-publisher", 4 | "version": "v2.0.0-beta.11", 5 | "description": "A plugin for Strapi Headless CMS that provides the ability to schedule publishing for any content type.", 6 | "scripts": { 7 | "lint": "eslint . --fix", 8 | "format": "prettier --write **/*.{ts,js,json,yml}", 9 | "build": "strapi-plugin build", 10 | "watch": "strapi-plugin watch", 11 | "watch:link": "strapi-plugin watch:link" 12 | }, 13 | "exports": { 14 | "./strapi-admin": { 15 | "source": "./admin/src/index.ts", 16 | "import": "./dist/admin/index.mjs", 17 | "require": "./dist/admin/index.js", 18 | "default": "./dist/admin/index.js" 19 | }, 20 | "./strapi-server": { 21 | "source": "./server/index.js", 22 | "import": "./dist/server/index.mjs", 23 | "require": "./dist/server/index.js", 24 | "default": "./dist/server/index.js" 25 | }, 26 | "./package.json": "./package.json" 27 | }, 28 | "author": { 29 | "name": "@ComfortablyCoding", 30 | "url": "https://github.com/ComfortablyCoding" 31 | }, 32 | "maintainers": [ 33 | { 34 | "name": "@PluginPal", 35 | "url": "https://github.com/PluginPal" 36 | } 37 | ], 38 | "homepage": "https://github.com/PluginPal/strapi-plugin-publisher#readme", 39 | "repository": { 40 | "type": "git", 41 | "url": "https://github.com/PluginPal/strapi-plugin-publisher.git" 42 | }, 43 | "bugs": { 44 | "url": "https://github.com/PluginPal/strapi-plugin-publisher/issues" 45 | }, 46 | "dependencies": { 47 | "lodash": "^4.17.21", 48 | "prop-types": "^15.8.1", 49 | "react-intl": "^6.6.2", 50 | "react-query": "^3.39.3" 51 | }, 52 | "devDependencies": { 53 | "@babel/core": "^7.23.3", 54 | "@babel/eslint-parser": "^7.23.3", 55 | "@babel/preset-react": "^7.23.3", 56 | "@strapi/sdk-plugin": "^5.2.7", 57 | "@strapi/strapi": "^5.2.0", 58 | "eslint": "^8.53.0", 59 | "eslint-config-prettier": "^9.0.0", 60 | "eslint-plugin-node": "^11.1.0", 61 | "eslint-plugin-react": "^7.33.2", 62 | "prettier": "^3.1.0", 63 | "react": "^18.2.0", 64 | "react-dom": "^18.2.0", 65 | "react-router-dom": "^6.0.0", 66 | "styled-components": "^6.0.0" 67 | }, 68 | "peerDependencies": { 69 | "@strapi/design-system": "^2.0.0-rc.11", 70 | "@strapi/icons": "^2.0.0-rc.11", 71 | "@strapi/strapi": "^5.2.0", 72 | "@strapi/utils": "^5.2.0", 73 | "react": "^17.0.0 || ^18.0.0", 74 | "react-router-dom": "^6.0.0", 75 | "styled-components": "^6.0.0" 76 | }, 77 | "strapi": { 78 | "displayName": "Publisher", 79 | "name": "publisher", 80 | "description": "A plugin for Strapi Headless CMS that provides the ability to schedule publishing for any content type.", 81 | "kind": "plugin" 82 | }, 83 | "engines": { 84 | "node": ">=18.0.0 <=20.x.x", 85 | "npm": ">=6.0.0" 86 | }, 87 | "keywords": [ 88 | "strapi", 89 | "strapi-plugin", 90 | "plugin", 91 | "strapi plugin", 92 | "publishing", 93 | "schedule publish" 94 | ], 95 | "license": "MIT" 96 | } 97 | -------------------------------------------------------------------------------- /server/bootstrap.js: -------------------------------------------------------------------------------- 1 | import registerCronTasks from './config/cron-tasks'; 2 | 3 | export default ({ strapi }) => { 4 | registerCronTasks({ strapi }); 5 | } 6 | -------------------------------------------------------------------------------- /server/config/cron-tasks.js: -------------------------------------------------------------------------------- 1 | import getPluginService from '../utils/getPluginService'; 2 | 3 | const registerCronTasks = ({ strapi }) => { 4 | const settings = getPluginService('settingsService').get(); 5 | // create cron check 6 | strapi.cron.add({ 7 | publisherCronTask: { 8 | options: settings.actions.syncFrequency, 9 | task: async () => { 10 | // fetch all actions that have passed 11 | const records = await getPluginService('action').find({ 12 | filters: { 13 | executeAt: { 14 | $lte: new Date(Date.now()), 15 | }, 16 | }, 17 | }); 18 | 19 | // process action records 20 | for (const record of records.results) { 21 | getPluginService('publicationService').toggle(record, record.mode); 22 | } 23 | }, 24 | }, 25 | }); 26 | } 27 | 28 | export default registerCronTasks; 29 | -------------------------------------------------------------------------------- /server/config/index.js: -------------------------------------------------------------------------------- 1 | import pluginConfigSchema from './schema'; 2 | 3 | export default { 4 | default: () => ({ 5 | enabled: true, 6 | actions: { 7 | syncFrequency: '*/1 * * * *', 8 | }, 9 | hooks: { 10 | beforePublish: () => {}, 11 | afterPublish: () => {}, 12 | beforeUnpublish: () => {}, 13 | afterUnpublish: () => {}, 14 | }, 15 | components: { 16 | dateTimePicker: { 17 | step: 5, 18 | }, 19 | }, 20 | }), 21 | validator: async (config) => { 22 | await pluginConfigSchema.validate(config); 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /server/config/schema.js: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | 3 | const pluginConfigSchema = yup.object().shape({ 4 | actions: yup 5 | .object() 6 | .shape({ 7 | syncFrequency: yup.string().optional(), 8 | }) 9 | .optional(), 10 | hooks: yup.object().optional(), 11 | components: yup 12 | .object({ 13 | dateTimePicker: yup 14 | .object({ 15 | step: yup.number().optional(), 16 | locale: yup.string().optional(), 17 | }) 18 | .optional(), 19 | }) 20 | .optional(), 21 | contentTypes: yup.array().of(yup.string()).optional(), 22 | }); 23 | 24 | export default pluginConfigSchema; 25 | -------------------------------------------------------------------------------- /server/content-types/action-content-type/index.js: -------------------------------------------------------------------------------- 1 | export default { 2 | kind: 'collectionType', 3 | collectionName: 'actions', 4 | info: { 5 | singularName: 'action', 6 | pluralName: 'actions', 7 | displayName: 'actions', 8 | }, 9 | pluginOptions: { 10 | 'content-manager': { 11 | visible: false, 12 | }, 13 | 'content-type-builder': { 14 | visible: false, 15 | }, 16 | }, 17 | options: { 18 | draftAndPublish: false, 19 | comment: '', 20 | }, 21 | attributes: { 22 | executeAt: { 23 | type: 'datetime', 24 | required: true, 25 | }, 26 | mode: { 27 | type: 'string', 28 | required: true, 29 | }, 30 | entityId: { 31 | type: 'string', 32 | required: true, 33 | }, 34 | entitySlug: { 35 | type: 'string', 36 | required: true, 37 | }, 38 | }, 39 | } 40 | -------------------------------------------------------------------------------- /server/content-types/index.js: -------------------------------------------------------------------------------- 1 | import actionContentType from './action-content-type'; 2 | 3 | export default { 4 | action: { schema: actionContentType }, 5 | } 6 | -------------------------------------------------------------------------------- /server/controllers/action-controller.js: -------------------------------------------------------------------------------- 1 | import { factories } from '@strapi/strapi'; 2 | 3 | /** 4 | * controller 5 | */ 6 | export default factories.createCoreController('plugin::publisher.action'); 7 | -------------------------------------------------------------------------------- /server/controllers/index.js: -------------------------------------------------------------------------------- 1 | import actionController from './action-controller'; 2 | import settingsController from './settings-controller'; 3 | 4 | export default { 5 | actionController, 6 | settingsController, 7 | }; 8 | -------------------------------------------------------------------------------- /server/controllers/settings-controller.js: -------------------------------------------------------------------------------- 1 | import getPluginService from '../utils/getPluginService'; 2 | 3 | export default { 4 | /** 5 | * Fetch the current plugin settings 6 | * 7 | * @return {Array} actions 8 | */ 9 | async find(ctx) { 10 | ctx.send({ data: getPluginService('settingsService').get() }); 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | import bootstrap from './bootstrap'; 2 | import config from './config'; 3 | import contentTypes from './content-types'; 4 | import controllers from './controllers'; 5 | import register from './register'; 6 | import routes from './routes'; 7 | import services from './services'; 8 | 9 | export default { 10 | bootstrap, 11 | register, 12 | config, 13 | contentTypes, 14 | controllers, 15 | routes, 16 | services, 17 | }; 18 | -------------------------------------------------------------------------------- /server/middlewares/index.js: -------------------------------------------------------------------------------- 1 | import validateBeforeScheduling from './validate-before-scheduling'; 2 | 3 | export default { 4 | validateBeforeScheduling, 5 | }; 6 | -------------------------------------------------------------------------------- /server/middlewares/validate-before-scheduling.js: -------------------------------------------------------------------------------- 1 | import { errors } from '@strapi/utils'; 2 | 3 | const validationMiddleware = async (context, next) => { 4 | const { uid, action, params } = context; 5 | // Run this middleware only for the publisher action. 6 | if (uid !== 'plugin::publisher.action') { 7 | return next(); 8 | } 9 | 10 | // Run it only for the create and update actions. 11 | if (action !== 'create' && action !== 'update') { 12 | return next(); 13 | } 14 | 15 | // The create action will have the data directly. 16 | let publisherAction = params.data; 17 | 18 | // The update action might have incomplete data, so we need to fetch it. 19 | if (action === 'update') { 20 | publisherAction = await strapi.documents('plugin::publisher.action').findOne({ 21 | documentId: params.documentId, 22 | }); 23 | } 24 | 25 | // The complete, and possibly updated, publisher action. 26 | const { entityId, entitySlug, mode, locale: actionLocale } = { 27 | ...publisherAction, 28 | ...params.data, 29 | }; 30 | 31 | // Run it only for the publish mode. 32 | if (mode !== 'publish') { 33 | return next(); 34 | } 35 | 36 | // Determine the final locale: use the provided locale first, otherwise fall back to the draft’s locale. 37 | const draft = await strapi.documents(entitySlug).findOne({ 38 | documentId: entityId, 39 | status: 'draft', 40 | locale: actionLocale, 41 | populate: '*', 42 | }); 43 | 44 | if (!draft) { 45 | throw new errors.NotFoundError( 46 | `No draft found for ${entitySlug} with documentId ${entityId}` 47 | ); 48 | } 49 | 50 | // If no locale was provided in params.data, fill it in from the draft 51 | const locale = actionLocale || draft.locale; 52 | 53 | // Fetch the published entity in this same locale 54 | const published = await strapi.documents(entitySlug).findOne({ 55 | documentId: entityId, 56 | status: 'published', 57 | locale, 58 | populate: '*', 59 | }); 60 | 61 | // Validate the draft before scheduling the publication. 62 | await strapi.entityValidator.validateEntityCreation( 63 | strapi.contentType(entitySlug), 64 | draft, 65 | { isDraft: false, locale }, 66 | published 67 | ); 68 | 69 | return next(); 70 | }; 71 | 72 | export default validationMiddleware; 73 | -------------------------------------------------------------------------------- /server/register.js: -------------------------------------------------------------------------------- 1 | import validateBeforeScheduling from './middlewares/validate-before-scheduling'; 2 | 3 | export default ({ strapi }) => { 4 | strapi.documents.use(validateBeforeScheduling); 5 | }; 6 | -------------------------------------------------------------------------------- /server/routes/admin/action-routes.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | method: 'GET', 4 | path: '/actions', 5 | handler: 'actionController.find', 6 | }, 7 | { 8 | method: 'POST', 9 | path: '/actions', 10 | handler: 'actionController.create', 11 | }, 12 | { 13 | method: 'DELETE', 14 | path: '/actions/:id', 15 | handler: 'actionController.delete', 16 | }, 17 | { 18 | method: 'PUT', 19 | path: '/actions/:id', 20 | handler: 'actionController.update', 21 | }, 22 | ]; 23 | -------------------------------------------------------------------------------- /server/routes/admin/index.js: -------------------------------------------------------------------------------- 1 | import actionRoutes from './action-routes'; 2 | import settingsRoutes from './settings-routes'; 3 | 4 | export default { 5 | type: 'admin', 6 | routes: [...actionRoutes, ...settingsRoutes], 7 | } 8 | -------------------------------------------------------------------------------- /server/routes/admin/settings-routes.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | method: 'GET', 4 | path: '/settings', 5 | handler: 'settingsController.find', 6 | }, 7 | ]; 8 | -------------------------------------------------------------------------------- /server/routes/content-api/action.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | method: 'GET', 4 | path: '/actions', 5 | handler: 'actionController.find', 6 | }, 7 | { 8 | method: 'POST', 9 | path: '/actions', 10 | handler: 'actionController.create', 11 | }, 12 | { 13 | method: 'DELETE', 14 | path: '/actions/:id', 15 | handler: 'actionController.delete', 16 | }, 17 | { 18 | method: 'PUT', 19 | path: '/actions/:id', 20 | handler: 'actionController.update', 21 | }, 22 | ]; 23 | -------------------------------------------------------------------------------- /server/routes/content-api/index.js: -------------------------------------------------------------------------------- 1 | import actionRoutes from './action'; 2 | 3 | export default { 4 | type: 'content-api', 5 | routes: [...actionRoutes], 6 | } 7 | -------------------------------------------------------------------------------- /server/routes/index.js: -------------------------------------------------------------------------------- 1 | import admin from './admin'; 2 | import contentApi from './content-api'; 3 | 4 | export default { 5 | admin, 6 | 'content-api': contentApi, 7 | }; 8 | -------------------------------------------------------------------------------- /server/services/action-service.js: -------------------------------------------------------------------------------- 1 | /** 2 | * service 3 | */ 4 | 5 | import { factories } from '@strapi/strapi'; 6 | 7 | export default factories.createCoreService('plugin::publisher.action'); 8 | -------------------------------------------------------------------------------- /server/services/emit-service.js: -------------------------------------------------------------------------------- 1 | const ENTRY_PUBLISH = 'entry.publish'; 2 | const ENTRY_UNPUBLISH = 'entry.unpublish'; 3 | 4 | export default ({ strapi }) => ({ 5 | async emit(event, uid, entity) { 6 | const model = strapi.getModel(uid); 7 | 8 | const sanitizedEntity = await strapi.contentAPI.sanitize.output(entity, model); 9 | 10 | await strapi.eventHub.emit(event, { 11 | model: model.modelName, 12 | entry: sanitizedEntity, 13 | }); 14 | }, 15 | 16 | async publish(uid, entity) { 17 | await this.emit(ENTRY_PUBLISH, uid, entity); 18 | }, 19 | 20 | async unpublish(uid, entity) { 21 | await this.emit(ENTRY_UNPUBLISH, uid, entity); 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /server/services/index.js: -------------------------------------------------------------------------------- 1 | import actionService from './action-service'; 2 | import emitService from './emit-service'; 3 | import publicationService from './publication-service'; 4 | import settingsService from './settings-service'; 5 | 6 | export default { 7 | action: actionService, 8 | emitService, 9 | publicationService, 10 | settingsService, 11 | } 12 | -------------------------------------------------------------------------------- /server/services/publication-service.js: -------------------------------------------------------------------------------- 1 | import getPluginService from '../utils/getPluginService'; 2 | import getPluginEntityUid from '../utils/getEntityUId'; 3 | 4 | const actionUId = getPluginEntityUid('action'); 5 | export default ({ strapi }) => ({ 6 | /** 7 | * Publish a single record 8 | * 9 | */ 10 | async publish(uid, entityId, { locale }) { 11 | try { 12 | const publishedEntity = await strapi.documents(uid).publish({ 13 | documentId: entityId, 14 | locale, 15 | }); 16 | const { hooks } = getPluginService('settingsService').get(); 17 | // emit publish event 18 | await hooks.beforePublish({ strapi, uid, entity: publishedEntity }); 19 | await getPluginService('emitService').publish(uid, publishedEntity); 20 | await hooks.afterPublish({ strapi, uid, entity: publishedEntity }); 21 | } catch (error) { 22 | strapi.log.error(`An error occurred when trying to publish document ${entityId} of type ${uid}: "${error}"`); 23 | } 24 | }, 25 | /** 26 | * Unpublish a single record 27 | * 28 | */ 29 | async unpublish(uid, entityId, { locale }) { 30 | try { 31 | const unpublishedEntity = await strapi.documents(uid).unpublish({ 32 | documentId: entityId, 33 | locale, 34 | }); 35 | const { hooks } = getPluginService('settingsService').get(); 36 | // Emit events 37 | await hooks.beforeUnpublish({ strapi, uid, entity: unpublishedEntity }); 38 | await getPluginService('emitService').unpublish(uid, unpublishedEntity); 39 | await hooks.afterUnpublish({ strapi, uid, entity: unpublishedEntity }); 40 | } catch (error) { 41 | strapi.log.error(`An error occurred when trying to unpublish document ${entityId} of type ${uid}: "${error}"`); 42 | } 43 | }, 44 | /** 45 | * Toggle a records publication state 46 | * 47 | */ 48 | async toggle(record, mode) { 49 | // handle single content type, id is always 1 50 | const entityId = record.entityId || 1; 51 | // Find the published entity 52 | const publishedEntity = await strapi.documents(record.entitySlug).findOne({ 53 | documentId: entityId, 54 | status: 'published', 55 | }); 56 | 57 | // Find the draft version of the entity 58 | const draftEntity = await strapi.documents(record.entitySlug).findOne({ 59 | documentId: entityId, 60 | status: 'draft', 61 | }); 62 | 63 | // Determine the current state of the entity 64 | const isPublished = !! publishedEntity; 65 | const isDraft = !! draftEntity; 66 | 67 | // Determine if the draft entity is newer than the published entity, if it's considered modified 68 | const isModified = isPublished && isDraft && draftEntity.updatedAt > publishedEntity.updatedAt; 69 | 70 | if (mode === 'publish' && ((!isPublished && isDraft) || isModified)) { 71 | await this.publish(record.entitySlug, entityId, { 72 | publishedAt: record.executeAt ? new Date(record.executeAt) : new Date(), 73 | locale: record.locale, 74 | }); 75 | } else if (mode === 'unpublish' && isPublished) { 76 | await this.unpublish(record.entitySlug, entityId, { 77 | locale: record.locale, 78 | }); 79 | } 80 | 81 | // Remove any used actions 82 | await strapi.documents(actionUId).delete({ 83 | documentId: record.documentId, 84 | }); 85 | }, 86 | }); 87 | -------------------------------------------------------------------------------- /server/services/settings-service.js: -------------------------------------------------------------------------------- 1 | import pluginId from '../utils/pluginId'; 2 | 3 | export default ({ strapi }) => ({ 4 | get() { 5 | return strapi.config.get(`plugin::${pluginId}`); 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /server/utils/getEntityUId.js: -------------------------------------------------------------------------------- 1 | import pluginId from './pluginId'; 2 | 3 | const getPluginEntityUid = (entity) => `plugin::${pluginId}.${entity}`; 4 | 5 | export default getPluginEntityUid; 6 | -------------------------------------------------------------------------------- /server/utils/getPluginService.js: -------------------------------------------------------------------------------- 1 | import pluginId from './pluginId'; 2 | 3 | /** 4 | * A helper function to obtain a plugin service 5 | * 6 | * @return service 7 | */ 8 | const getPluginService = (name) => strapi.plugin(pluginId).service(name); 9 | 10 | export default getPluginService; 11 | -------------------------------------------------------------------------------- /server/utils/pluginId.js: -------------------------------------------------------------------------------- 1 | import pluginPkg from '../../package.json'; 2 | 3 | /** 4 | * Returns the plugin id 5 | * 6 | * @return plugin id 7 | */ 8 | const pluginId = pluginPkg.strapi.name; 9 | 10 | export default pluginId; 11 | -------------------------------------------------------------------------------- /server/utils/relations.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | // constants 4 | const ID_ATTRIBUTE = 'id'; 5 | const CREATED_AT_ATTRIBUTE = 'createdAt'; 6 | const UPDATED_AT_ATTRIBUTE = 'updatedAt'; 7 | 8 | export function isAnyToMany(attribute) { 9 | return ( 10 | isRelationalAttribute(attribute) && ['oneToMany', 'manyToMany'].includes(attribute.relation) 11 | ); 12 | } 13 | 14 | function isRelationalAttribute(attribute) { 15 | return attribute && attribute.type === 'relation'; 16 | } 17 | 18 | export function isVisibleAttribute(model, attributeName) { 19 | return getVisibleAttributes(model).includes(attributeName); 20 | } 21 | 22 | function getVisibleAttributes(model) { 23 | return _.difference(_.keys(model.attributes), getNonVisibleAttributes(model)); 24 | } 25 | 26 | function getNonVisibleAttributes(model) { 27 | const nonVisibleAttributes = _.reduce( 28 | model.attributes, 29 | (acc, attr, attrName) => (attr.visible === false ? acc.concat(attrName) : acc), 30 | [] 31 | ); 32 | 33 | return _.uniq([ID_ATTRIBUTE, ...getTimestamps(model), ...nonVisibleAttributes]); 34 | } 35 | 36 | function getTimestamps(model) { 37 | const attributes = []; 38 | 39 | if (_.has(CREATED_AT_ATTRIBUTE, model.attributes)) { 40 | attributes.push(CREATED_AT_ATTRIBUTE); 41 | } 42 | 43 | if (_.has(UPDATED_AT_ATTRIBUTE, model.attributes)) { 44 | attributes.push(UPDATED_AT_ATTRIBUTE); 45 | } 46 | 47 | return attributes; 48 | } -------------------------------------------------------------------------------- /strapi-admin.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./admin/src').default; 4 | -------------------------------------------------------------------------------- /strapi-server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./server'); 4 | --------------------------------------------------------------------------------