├── .distignore ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Gruntfile.js ├── assets ├── css │ └── scss │ │ ├── partials │ │ ├── _addons.scss │ │ ├── _listings.scss │ │ └── _player.scss │ │ ├── wp101-admin.scss │ │ └── wp101.scss └── js │ └── src │ ├── addons.js │ ├── playlist.js │ └── settings.js ├── bin └── install-wp-tests.sh ├── composer.json ├── composer.lock ├── includes ├── addons.php ├── admin.php ├── class-api.php ├── class-wp101-plugin.php ├── deprecated.php ├── migrate.php ├── shortcode.php ├── template-tags.php └── uninstall.php ├── languages └── wp101.pot ├── package-lock.json ├── package.json ├── phpcs.xml ├── phpunit.xml.dist ├── plugin-repo-assets ├── banner-1544x500.jpg ├── banner-772x250.jpg ├── icon-128x128.jpg ├── icon-256x256.jpg ├── screenshot-1.jpg ├── screenshot-2.jpg ├── screenshot-3.jpg ├── screenshot-4.jpg ├── screenshot-5.jpg └── screenshot-6.jpg ├── readme.txt ├── tests ├── bootstrap.php ├── test-addons.php ├── test-admin.php ├── test-api.php ├── test-deprecated.php ├── test-migrate.php ├── test-settings.php ├── test-shortcode.php ├── test-template-tags.php ├── test-uninstall.php └── testcase.php ├── views ├── add-ons.php ├── listings.php └── settings.php └── wp101.php /.distignore: -------------------------------------------------------------------------------- 1 | # A set of files you probably don't want in your WordPress.org distribution 2 | .distignore 3 | .editorconfig 4 | .git 5 | .gitignore 6 | .gitlab-ci.yml 7 | .travis.yml 8 | .DS_Store 9 | Thumbs.db 10 | behat.yml 11 | bin 12 | circle.yml 13 | composer.json 14 | composer.lock 15 | Gruntfile.js 16 | package.json 17 | package-lock.json 18 | phpunit.xml 19 | phpunit.xml.dist 20 | multisite.xml 21 | multisite.xml.dist 22 | phpcs.xml 23 | phpcs.xml.dist 24 | README.md 25 | wp-cli.local.yml 26 | yarn.lock 27 | tests 28 | vendor 29 | node_modules 30 | *.sql 31 | *.tar.gz 32 | *.zip 33 | plugin-repo-assets 34 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | # WordPress Coding Standards 5 | # https://make.wordpress.org/core/handbook/coding-standards/ 6 | 7 | root = true 8 | 9 | [*] 10 | charset = utf-8 11 | end_of_line = lf 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | indent_style = tab 15 | 16 | [{.jshintrc,*.json,*.yml}] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [{*.txt,wp-config-sample.php}] 21 | end_of_line = crlf 22 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "wordpress", 3 | "rules": { 4 | "no-undef": 2, 5 | "space-before-function-paren": "off", 6 | "space-in-parens": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | assets/css/*.css 2 | assets/css/*.map 3 | assets/js/*.js 4 | assets/js/*.map 5 | dist 6 | node_modules 7 | tests/coverage 8 | vendor 9 | .DS_Store 10 | Thumbs.db 11 | *.codekit 12 | *.log 13 | *.scssc 14 | .idea 15 | /svn/ 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: php 4 | 5 | notifications: 6 | email: false 7 | 8 | cache: 9 | directories: 10 | - vendor 11 | - $HOME/.composer/cache 12 | 13 | php: 14 | - 7.3 15 | - 7.2 16 | - 7.1 17 | - 7.0 18 | 19 | env: 20 | - WP_VERSION=latest 21 | - WP_VERSION=5.0 22 | - WP_VERSION=4.9.9 23 | 24 | matrix: 25 | fast_finish: true 26 | include: 27 | - name: Coding Standards 28 | php: 7.2 29 | env: WP_VERSION=latest RUN_PHPCS=1 30 | - name: Bleeding Edge 31 | php: nightly 32 | env: WP_VERSION=trunk 33 | exclude: 34 | # WordPress < 5.0 doesn't officially support PHP 7.3 35 | - php: 7.3 36 | env: WP_VERSION=4.9.9 37 | allow_failures: 38 | - name: Bleeding Edge 39 | php: nightly 40 | env: WP_VERSION=trunk 41 | 42 | 43 | install: 44 | - composer install --prefer-dist 45 | - bash bin/install-wp-tests.sh wordpress_test root '' localhost $WP_VERSION 46 | - sh vendor/bin/install-runkit.sh 47 | 48 | script: 49 | - | 50 | if [[ ${RUN_PHPCS} ]]; then 51 | ./vendor/bin/phpcs 52 | else 53 | WP_MULTISITE=0 ./vendor/bin/phpunit 54 | WP_MULTISITE=1 ./vendor/bin/phpunit 55 | fi 56 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) as of version 5.0.0. 6 | 7 | ## [5.1.0] 8 | * Run migrations across a multisite network via a background task ([#48]). 9 | * Store public API keys based on the site URL, enabling better handling of domain changes ([#52]). 10 | * Add the `wp101_excluded_topics` filter ([#53]). 11 | ```php 12 | /** 13 | * Exclude "some-video-slug" and "some-plugin.legacy_id" from appearing in 14 | * the WP101 video playlist. 15 | */ 16 | add_filter( 'wp101_excluded_topics', function ( $topics ) { 17 | $topics[] = 'some-video-slug'; 18 | $topics[] = 'some-plugin.legacy_id'; 19 | 20 | return $topics; 21 | } ); 22 | ``` 23 | 24 | ## [5.0.1] 25 | * Ensure that legacy API keys are exchanged before making any other API requests ([#45]). 26 | 27 | ## [5.0.0] 28 | * Complete rewrite of the plugin and backing APIs to bring even more content to the WP101 plugin. 29 | * Custom videos, course visibility, and permissions are now controlled via [the WP101 Plugin app](https://app.wp101plugin.com). 30 | 31 | ## 4.2.1 32 | * In addition to whether or not the Classic Editor plugin is installed and activated, this minor fix also checks to see if filters are being used to disable Gutenberg. If so, display the previous version of our WordPress 101 videos instead of the new videos for Gutenberg and 5.0. Thanks, Cliff Seal! 33 | 34 | ## 4.2 35 | * Re-added the old WordPress 101 videos for the Classic Editor, provided that plugin is also installed and activated. 36 | * Added function `get_wpclassic_topics` 37 | 38 | ## 4.1 39 | * Brand new WordPress 101 video tutorial series, completely rewritten for the all-new Gutenberg Block Editor in WordPress 5.0! 40 | 41 | ## 4.0.2 42 | * Return the ‘plugin_action_links_’ filter argument in all cases. Previously, it was only returned if the authorization check succeeded, causing errors in some edge-cases. 43 | 44 | ## 4.0.1 45 | * Transient for get_help_topics was shortened for testing, but left in the last release. It's now good for a day. Nothing to see here. Move along. 46 | 47 | ## 4.0 48 | * Jetpack and WooCommerce videos are now included, for a total of 90 tutorial videos! 49 | * Collapsible sections to make the long list of videos more manageable. 50 | * Added a Settings link on the Plugins page, if user is authorized. 51 | * Minor CSS revisions and bug fixes. 52 | 53 | ## 3.2.3 54 | * Updated for new translation system on WordPress.org. 55 | 56 | ## 3.2.2 57 | * Minor changes to description verbiage and fixed a tiny typo. 58 | * Tested and verified for WordPress 4.3! 59 | 60 | ## 3.2.1 61 | * Changed title to reflect the new name of the Yoast SEO plugin. 62 | 63 | ## 3.2 64 | * Updated the Yoast SEO plugin videos for version 2.0. 65 | * Tested and verified for WordPress 4.2! 66 | 67 | ## 3.1 68 | * By popular request, we’ve now added the ability to limit access to the settings panel to a specific administrator. 69 | * We've also added several new filters to facilitate overrides for this new feature. See the FAQ for documentation on these new filters. Thanks, Justin Sainton! 70 | * Last, we’ve assigned the plugin instance to a (global) variable, to make it accessible outside the plugin for modifications. Thanks, John Sundberg! 71 | 72 | ## 3.0.4 73 | * Bug fixes for hiding and showing all the Yoast SEO videos. Thanks, Justin Sainton! 74 | 75 | ## 3.0.3 76 | * Added more detailed docs on the built-in hooks to filter the list of videos, or even add your own. Thanks, Justin Sainton! 77 | 78 | ## 3.0.2 79 | * CSS bug fix for Firefox. 80 | 81 | ## 3.0.1 82 | * Bug fix for unexpected T_PAAMAYIM_NEKUDOTAYIM error on PHP 5.2 and older. 83 | 84 | ## 3.0 85 | * We’ve added videos for the Yoast SEO plugin, provided that plugin is installed. 86 | * Added new filters for developers. You can now filter the topics and videos returned on wp101_get_help_topics and wp101_get_custom_help_topics. 87 | * Increased the default size of the video player, plus added responsive support for all your devices! 88 | * Minor coding standards cleanup. 89 | 90 | ## 2.1.1 91 | * Bug fix for missing wp101_icon_url error. 92 | 93 | ## 2.1 94 | * Updated for WordPress 3.8, including new menu icon. 95 | 96 | ## 2.0.6 97 | * Bug fix for missing api_key_notset_message. 98 | 99 | ## 2.0.5 100 | * Fixed issue with hiding the first video. 101 | 102 | ## 2.0.4 103 | * Replaced mentions of "WP101" with "Video Tutorials" 104 | * Replaced icons with a more generic icon. 105 | * Removed "Part 1," "Part 2," etc. from video titles. 106 | * Updated screenshots. 107 | 108 | ## 2.0.3 109 | * Fix to ensure hardcoded API keys are not lost on upgrade. 110 | 111 | ## 2.0.2 112 | * Bug fix to address "API key not valid" error on multisite installations. 113 | * Removed redundant notification when API key is not set. 114 | 115 | ## 2.0.1 116 | * Minor fix to ensure the actively-playing video title is bold. 117 | 118 | ## 2.0 119 | * Added the ability to selectively choose which videos appear in the list. 120 | * Added the ability to add your own custom videos to the list. 121 | 122 | ## 1.1.1 123 | * Minor change to ensure hardcoded API keys are written to the database. 124 | * Added a small icon to the menu item to help it be more easily visible. 125 | 126 | ## 1.1 127 | * Moved WP101 to its own separate menu item at the bottom of the navigation menu. 128 | * Changed the API Key input field to a password field, instead of a regular text field. 129 | * Granted permissions for logged-in Subscribers to view the videos. 130 | 131 | ## 1.0.1 132 | * Minor bug fix for multisite installations. 133 | 134 | ## 1.0 135 | * First version! 136 | 137 | [Unreleased]: https://github.com/101videos/wp101plugin/compare/master...develop 138 | [5.0.0]: https://github.com/leftlane/wp101plugin/releases/tag/v5.0.0 139 | [5.0.1]: https://github.com/leftlane/wp101plugin/releases/tag/v5.0.1 140 | [5.1.0]: https://github.com/leftlane/wp101plugin/releases/tag/v5.1.0 141 | [#45]: https://github.com/leftlane/wp101plugin/issues/45 142 | [#48]: https://github.com/101videos/wp101plugin/pull/48 143 | [#52]: https://github.com/101videos/wp101plugin/pull/52 144 | [#53]: https://github.com/101videos/wp101plugin/pull/53 145 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the development of the WP101 Plugin 2 | 3 | The WP101 Plugin is the client-facing end of [the WP101 Plugin SaaS](https://app.wp101plugin.com) and is used to present video training right within WordPress. 4 | 5 | ## Branching strategy 6 | 7 | All new branches should be branched off of `develop`, which is the default branch for the repo. The `master` branch represents the latest stable release. 8 | 9 | ## Installing dependencies 10 | 11 | After cloning the repository, development dependencies are installed via [npm](https://npmjs.com) and [Composer](https://getcomposer.org): 12 | 13 | ```sh 14 | # Install JavaScript dependencies 15 | $ npm install 16 | 17 | # Install PHP dependencies 18 | $ composer install 19 | ``` 20 | 21 | ## Coding standards 22 | 23 | This plugin uses [the WordPress coding standards](https://make.wordpress.org/core/handbook/best-practices/coding-standards/php/), which are enforced via [PHP_CodeSniffer](https://github.com/squizlabs/PHP_CodeSniffer). 24 | 25 | ## Compiling assets 26 | 27 | Front-end assets (CSS and JavaScript) are light in the plugin, but are compiled and concatenated via [Grunt]: 28 | 29 | ```sh 30 | # Compile assets. 31 | $ grunt 32 | ``` 33 | 34 | ## Running tests 35 | 36 | Tests for the plugin are written using [the WordPress core test suite](https://make.wordpress.org/core/handbook/testing/automated-testing/phpunit/), and are run automatically as part of the plugin's Continuous Integration (CI) pipeline. 37 | 38 | When contributing code, please include appropriate tests! 39 | 40 | ## Building a release 41 | 42 | When the plugin is ready for release, we use [Grunt] to build our release: 43 | 44 | ```sh 45 | $ grunt build 46 | ``` 47 | 48 | This will compile all assets, then copy the files necessary for inclusion into a `dist/` directory. This directory can be copied directly into the `trunk` directory of the WordPress plugin's Subversion repo: 49 | 50 | ```sh 51 | cp -r dist/* /path/to/svn/wp101/trunk 52 | ``` 53 | 54 | [Grunt]: https://gruntjs.com/ 55 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 'use strict'; 3 | 4 | grunt.initConfig({ 5 | 6 | pkg: grunt.file.readJSON('package.json'), 7 | 8 | copy: { 9 | main: { 10 | src: [ 11 | 'assets/**', 12 | '!assets/*/scss/**', 13 | '!assets/*/scss', 14 | '!assets/*/src/**', 15 | '!assets/*/src', 16 | 'includes/**', 17 | 'languages/**', 18 | 'views/**', 19 | 'composer.json', 20 | 'readme.txt', 21 | 'wp101.php' 22 | ], 23 | dest: 'dist/' 24 | }, 25 | }, 26 | 27 | cssmin: { 28 | options: { 29 | sourceMap: true 30 | }, 31 | target: { 32 | files: { 33 | 'assets/css/admin.min.css': [ 34 | 'assets/admin.css' 35 | ], 36 | 'assets/css/wp101-admin.min.css': [ 37 | 'assets/wp101-admin.css' 38 | ] 39 | } 40 | } 41 | }, 42 | 43 | eslint: { 44 | options: { 45 | configFile: '.eslintrc' 46 | }, 47 | target: [ 48 | 'assets/js/src/**.js' 49 | ] 50 | }, 51 | 52 | uglify: { 53 | options: { 54 | banner: '/*! WP101 - v<%= pkg.version %> */', 55 | sourceMap: true 56 | }, 57 | main: { 58 | files: { 59 | 'assets/js/wp101-addons.min.js': [ 60 | 'assets/js/src/addons.js' 61 | ], 62 | 'assets/js/wp101-admin.min.js': [ 63 | 'assets/js/src/playlist.js', 64 | 'assets/js/src/settings.js' 65 | ] 66 | } 67 | } 68 | }, 69 | 70 | makepot: { 71 | target: { 72 | options: { 73 | domainPath: '/languages', 74 | exclude: [ 75 | '\.git/*', 76 | 'bin/*', 77 | 'node_modules/*', 78 | 'tests/*' 79 | ], 80 | mainFile: 'wp101.php', 81 | potFilename: 'wp101.pot', 82 | potHeaders: { 83 | poedit: true, 84 | 'x-poedit-keywordslist': true 85 | }, 86 | type: 'wp-plugin', 87 | updateTimestamp: false 88 | } 89 | } 90 | }, 91 | 92 | sass: { 93 | options: { 94 | outputStyle: 'compressed', 95 | sourceMap: true 96 | }, 97 | dist: { 98 | files: [{ 99 | expand: true, 100 | cwd: 'assets/css/scss', 101 | src: ['*.scss'], 102 | dest: 'assets/css', 103 | ext: '.css' 104 | }] 105 | } 106 | } 107 | }); 108 | 109 | grunt.loadNpmTasks( 'grunt-sass' ); 110 | grunt.loadNpmTasks( 'grunt-contrib-copy' ); 111 | grunt.loadNpmTasks( 'grunt-contrib-uglify' ); 112 | grunt.loadNpmTasks( 'grunt-eslint' ); 113 | grunt.loadNpmTasks( 'grunt-wp-i18n' ); 114 | 115 | grunt.registerTask( 'build', [ 'eslint', 'i18n', 'sass', 'uglify', 'copy' ] ); 116 | grunt.registerTask( 'i18n', [ 'makepot' ] ); 117 | grunt.registerTask( 'default', [ 'eslint', 'sass', 'uglify' ] ); 118 | 119 | grunt.util.linefeed = '\n'; 120 | }; 121 | -------------------------------------------------------------------------------- /assets/css/scss/partials/_addons.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Styles for the add-ons page. 3 | * 4 | * @package WP101 5 | */ 6 | 7 | .wp101-addon-list { 8 | display: flex; 9 | flex-wrap: wrap; 10 | margin-top: -20px; 11 | margin-right: -20px; 12 | 13 | .card { 14 | display: flex; 15 | flex-wrap: nowrap; 16 | flex-direction: column; 17 | justify-content: flex-start; 18 | margin-right: 20px; 19 | } 20 | 21 | .wp101-addon-button, .notice { 22 | align-self: flex-end; 23 | margin-top: auto; 24 | } 25 | 26 | ol { 27 | margin-top: 0; 28 | } 29 | } 30 | 31 | .wp101-addon-tag { 32 | position: relative; 33 | top: -1px; 34 | display: inline-block; 35 | margin-left: .6em; 36 | padding: .3em .4em; 37 | font-size: .65em; 38 | line-height: 1; 39 | text-transform: lowercase; 40 | vertical-align: middle; 41 | text-shadow: 0 0 1px #777; 42 | border-radius: 2px; 43 | 44 | &.subscribed { 45 | color: #fff; 46 | background: #008758; 47 | } 48 | } 49 | 50 | .wp101-addon-description { 51 | p:first-child { 52 | margin-top: 0; 53 | } 54 | } 55 | 56 | .wp101-addon-more-topics { 57 | list-style: none; 58 | } 59 | -------------------------------------------------------------------------------- /assets/css/scss/partials/_listings.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Styles for the listing page. 3 | * 4 | * @package WP101 5 | */ 6 | 7 | .wp101-playlist { 8 | box-sizing: border-box; 9 | 10 | .wp101-series { 11 | h2 { 12 | position: relative; 13 | padding-top: 5px; 14 | padding-bottom: 5px; 15 | line-height: 1.4; 16 | border-bottom: 1px solid #ddd; 17 | } 18 | } 19 | 20 | // Styling for the accordion indicator. 21 | .ui-accordion-header { 22 | padding-right: 26px; 23 | cursor: pointer; 24 | 25 | &:hover, &:focus, &:active { 26 | .ui-accordion-header-icon { 27 | color: inherit; 28 | } 29 | } 30 | 31 | &.ui-state-active { 32 | .ui-accordion-header-icon { 33 | &:before { 34 | content: '\f142'; 35 | } 36 | } 37 | } 38 | } 39 | 40 | .ui-accordion-header-icon { 41 | position: absolute; 42 | left: auto; 43 | right: 0; 44 | font: normal 20px/1 dashicons; 45 | color: #72777c; 46 | 47 | &:before { 48 | content: '\f140'; 49 | } 50 | } 51 | } 52 | 53 | .wp101-topics-list { 54 | .active { 55 | font-weight: bold; 56 | text-decoration: none; 57 | color: inherit; 58 | } 59 | } 60 | 61 | .wp101-addon-notice { 62 | border-top: 1px solid #ddd; 63 | } 64 | 65 | @media (min-width: 1028px) { 66 | .wp101-media { 67 | float: right; 68 | width: calc(100% - 300px); 69 | } 70 | 71 | .wp101-playlist { 72 | float: left; 73 | width: 280px; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /assets/css/scss/partials/_player.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Styles for the WP101 players. 3 | * 4 | * @package WP101 5 | */ 6 | 7 | .wp101-player-wrap, .wp101-video-wrapper { 8 | position: relative; 9 | height: 0; 10 | overflow: hidden; 11 | padding-bottom: 9/16 * 100%; 12 | 13 | iframe { 14 | position: absolute; 15 | left: 0; 16 | top: 0; 17 | width: 100%; 18 | height: 100%; 19 | border: none; 20 | } 21 | } 22 | 23 | .wp101-video-details { 24 | padding-top: 1em; 25 | } 26 | 27 | .wp101-video-grid-contents { 28 | display: flex; 29 | flex-wrap: wrap; 30 | align-items: flex-start; 31 | justify-content: space-between; 32 | 33 | .wp101-video { 34 | flex: 1 0 50%; 35 | max-width: 48%; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /assets/css/scss/wp101-admin.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Styles for WP101 in the admin area. 3 | * 4 | * @package WP101 5 | */ 6 | 7 | @import "partials/addons"; 8 | @import "partials/listings"; 9 | @import "partials/player"; 10 | -------------------------------------------------------------------------------- /assets/css/scss/wp101.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * Styles for WP101 on the front-end of a site. 3 | * 4 | * @package WP101 5 | */ 6 | 7 | @import "partials/player"; 8 | -------------------------------------------------------------------------------- /assets/js/src/addons.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Scripting to save dismissed WP101 add-on notifications. 3 | * 4 | * @package WP101 5 | */ 6 | /* global ajaxurl, wp101Addons */ 7 | 8 | (function ($) { 9 | 'use strict'; 10 | 11 | document.addEventListener('click', function (e) { 12 | if ('BUTTON' !== e.target.tagName || ! e.target.classList.contains('notice-dismiss')) { 13 | return; 14 | } 15 | 16 | $.post(ajaxurl, { 17 | action: 'wp101_dismiss_notice', 18 | addons: e.target.parentElement.dataset.wp101AddonSlug.split(','), 19 | nonce: wp101Addons.nonce 20 | }); 21 | }); 22 | }(jQuery)); 23 | -------------------------------------------------------------------------------- /assets/js/src/playlist.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Scripts for the WP101 playlist functionality within WP Admin. 3 | * 4 | * @package WP101 5 | */ 6 | /* globals jQuery */ 7 | 8 | (function ($) { 9 | 'use strict'; 10 | 11 | var $playlist = $('.wp101-playlist'), 12 | $series = $playlist.find('.wp101-series'), 13 | title = document.getElementById('wp101-player-title'), 14 | player = document.getElementById('wp101-player'); 15 | 16 | /** 17 | * Read window.location.hash and return the HTMLElement representing that topic 18 | * in the playlist. 19 | * 20 | * If no match is found, return the first playlist item. 21 | */ 22 | function getCurrentTopic() { 23 | var hash = window.location.hash.substring(1), 24 | topic; 25 | 26 | if (! hash && window.sessionStorage.wp101CurrentVideo) { 27 | hash = window.sessionStorage.wp101CurrentVideo; 28 | 29 | // Attempt to update the hash via the history API. 30 | if (window.history) { 31 | window.history.replaceState(history.state, '', '#' + hash); 32 | } 33 | } 34 | 35 | topic = document.querySelector('a[data-media-slug="' + hash + '"]'); 36 | 37 | return topic || document.querySelector('.wp101-topics-list a'); 38 | } 39 | 40 | /** 41 | * Load a topic based on its playlist node. 42 | * 43 | * @param HTMLElement el - The playlist node. 44 | */ 45 | function loadTopic(el) { 46 | el = el || getCurrentTopic(); 47 | 48 | if (! el) { 49 | return; 50 | } 51 | 52 | // Set the accordion position. 53 | $playlist.accordion('option', 'active', $series.index(el.parentElement.parentElement.parentElement)); 54 | 55 | $playlist.find('a.active').removeClass('active'); 56 | el.classList.add('active'); 57 | 58 | player.src = el.dataset.mediaSrc; 59 | title.innerText = el.dataset.mediaTitle; 60 | 61 | // Store the latest video in sessionStorage. 62 | window.sessionStorage.wp101CurrentVideo = el.dataset.mediaSlug; 63 | } 64 | 65 | // Detect changes to window.location.hash. 66 | window.addEventListener('hashchange', function () { 67 | loadTopic(); 68 | }); 69 | 70 | // Enable jQuery accordion for list of series. 71 | $playlist.accordion({ 72 | collapsible: true, 73 | header: '.wp101-series h2', 74 | heightStyle: 'content', 75 | activate: function () { 76 | sessionStorage.setItem('wp101ListState', $playlist.accordion('option', 'active')); 77 | }, 78 | active: parseInt(sessionStorage.getItem('wp101ListState'), 10) 79 | }); 80 | 81 | // Load the default topic. 82 | loadTopic(); 83 | }(jQuery)); 84 | -------------------------------------------------------------------------------- /assets/js/src/settings.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Scripts for the WP101 settings screen within WP Admin. 3 | * 4 | * @package WP101 5 | */ 6 | 7 | (function () { 8 | 'use strict'; 9 | 10 | var settingsForm = document.getElementById('wp101-settings-api-key-form'), 11 | settingsDisplay = document.getElementById('wp101-settings-api-key-display'); 12 | 13 | // Abort if we're not on the WP101 Settings screen. 14 | if (! settingsForm) { 15 | return; 16 | } 17 | 18 | /* 19 | * If an API key has already been set, we'll display a masked version. 20 | * 21 | * Clicking the button within settingsDisplay will replace the display with the previously- 22 | * hidden form. 23 | */ 24 | if (settingsDisplay && settingsForm.classList.contains('hide-if-js')) { 25 | settingsForm.setAttribute('hidden', ''); 26 | settingsForm.classList.remove('hide-if-js'); 27 | 28 | settingsDisplay.addEventListener('click', function (e) { 29 | if ('BUTTON' !== e.target.tagName) { 30 | return; 31 | } 32 | 33 | settingsDisplay.setAttribute('hidden', ''); 34 | settingsForm.removeAttribute('hidden'); 35 | document.getElementById('wp101-api-key').focus(); 36 | }); 37 | } 38 | }()); 39 | -------------------------------------------------------------------------------- /bin/install-wp-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | if [ $# -lt 3 ]; then 4 | echo "usage: $0 [db-host] [wp-version] [skip-database-creation]" 5 | exit 1 6 | fi 7 | 8 | DB_NAME=$1 9 | DB_USER=$2 10 | DB_PASS=$3 11 | DB_HOST=${4-localhost} 12 | WP_VERSION=${5-latest} 13 | SKIP_DB_CREATE=${6-false} 14 | 15 | WP_TESTS_DIR=${WP_TESTS_DIR-/tmp/wordpress-tests-lib} 16 | WP_CORE_DIR=${WP_CORE_DIR-/tmp/wordpress/} 17 | 18 | download() { 19 | if [ `which curl` ]; then 20 | curl -s "$1" > "$2"; 21 | elif [ `which wget` ]; then 22 | wget -nv -O "$2" "$1" 23 | fi 24 | } 25 | 26 | if [[ $WP_VERSION =~ [0-9]+\.[0-9]+(\.[0-9]+)? ]]; then 27 | WP_TESTS_TAG="tags/$WP_VERSION" 28 | elif [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 29 | WP_TESTS_TAG="trunk" 30 | else 31 | # http serves a single offer, whereas https serves multiple. we only want one 32 | download http://api.wordpress.org/core/version-check/1.7/ /tmp/wp-latest.json 33 | grep '[0-9]+\.[0-9]+(\.[0-9]+)?' /tmp/wp-latest.json 34 | LATEST_VERSION=$(grep -o '"version":"[^"]*' /tmp/wp-latest.json | sed 's/"version":"//') 35 | if [[ -z "$LATEST_VERSION" ]]; then 36 | echo "Latest WordPress version could not be found" 37 | exit 1 38 | fi 39 | WP_TESTS_TAG="tags/$LATEST_VERSION" 40 | fi 41 | 42 | set -ex 43 | 44 | install_wp() { 45 | 46 | if [ -d $WP_CORE_DIR ]; then 47 | return; 48 | fi 49 | 50 | mkdir -p $WP_CORE_DIR 51 | 52 | if [[ $WP_VERSION == 'nightly' || $WP_VERSION == 'trunk' ]]; then 53 | mkdir -p /tmp/wordpress-nightly 54 | download https://wordpress.org/nightly-builds/wordpress-latest.zip /tmp/wordpress-nightly/wordpress-nightly.zip 55 | unzip -q /tmp/wordpress-nightly/wordpress-nightly.zip -d /tmp/wordpress-nightly/ 56 | mv /tmp/wordpress-nightly/wordpress/* $WP_CORE_DIR 57 | else 58 | if [ $WP_VERSION == 'latest' ]; then 59 | local ARCHIVE_NAME='latest' 60 | else 61 | local ARCHIVE_NAME="wordpress-$WP_VERSION" 62 | fi 63 | download https://wordpress.org/${ARCHIVE_NAME}.tar.gz /tmp/wordpress.tar.gz 64 | tar --strip-components=1 -zxmf /tmp/wordpress.tar.gz -C $WP_CORE_DIR 65 | fi 66 | 67 | download https://raw.github.com/markoheijnen/wp-mysqli/master/db.php $WP_CORE_DIR/wp-content/db.php 68 | } 69 | 70 | install_test_suite() { 71 | # portable in-place argument for both GNU sed and Mac OSX sed 72 | if [[ $(uname -s) == 'Darwin' ]]; then 73 | local ioption='-i .bak' 74 | else 75 | local ioption='-i' 76 | fi 77 | 78 | # set up testing suite if it doesn't yet exist 79 | if [ ! -d $WP_TESTS_DIR ]; then 80 | # set up testing suite 81 | mkdir -p $WP_TESTS_DIR 82 | svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/includes/ $WP_TESTS_DIR/includes 83 | svn co --quiet https://develop.svn.wordpress.org/${WP_TESTS_TAG}/tests/phpunit/data/ $WP_TESTS_DIR/data 84 | fi 85 | 86 | if [ ! -f wp-tests-config.php ]; then 87 | download https://develop.svn.wordpress.org/${WP_TESTS_TAG}/wp-tests-config-sample.php "$WP_TESTS_DIR"/wp-tests-config.php 88 | # remove all forward slashes in the end 89 | WP_CORE_DIR=$(echo $WP_CORE_DIR | sed "s:/\+$::") 90 | sed $ioption "s:dirname( __FILE__ ) . '/src/':'$WP_CORE_DIR/':" "$WP_TESTS_DIR"/wp-tests-config.php 91 | sed $ioption "s/youremptytestdbnamehere/$DB_NAME/" "$WP_TESTS_DIR"/wp-tests-config.php 92 | sed $ioption "s/yourusernamehere/$DB_USER/" "$WP_TESTS_DIR"/wp-tests-config.php 93 | sed $ioption "s/yourpasswordhere/$DB_PASS/" "$WP_TESTS_DIR"/wp-tests-config.php 94 | sed $ioption "s|localhost|${DB_HOST}|" "$WP_TESTS_DIR"/wp-tests-config.php 95 | fi 96 | 97 | } 98 | 99 | install_db() { 100 | 101 | if [ ${SKIP_DB_CREATE} = "true" ]; then 102 | return 0 103 | fi 104 | 105 | # parse DB_HOST for port or socket references 106 | local PARTS=(${DB_HOST//\:/ }) 107 | local DB_HOSTNAME=${PARTS[0]}; 108 | local DB_SOCK_OR_PORT=${PARTS[1]}; 109 | local EXTRA="" 110 | 111 | if ! [ -z $DB_HOSTNAME ] ; then 112 | if [ $(echo $DB_SOCK_OR_PORT | grep -e '^[0-9]\{1,\}$') ]; then 113 | EXTRA=" --host=$DB_HOSTNAME --port=$DB_SOCK_OR_PORT --protocol=tcp" 114 | elif ! [ -z $DB_SOCK_OR_PORT ] ; then 115 | EXTRA=" --socket=$DB_SOCK_OR_PORT" 116 | elif ! [ -z $DB_HOSTNAME ] ; then 117 | EXTRA=" --host=$DB_HOSTNAME --protocol=tcp" 118 | fi 119 | fi 120 | 121 | # create database 122 | mysqladmin create $DB_NAME --user="$DB_USER" --password="$DB_PASS"$EXTRA 123 | } 124 | 125 | install_wp 126 | install_test_suite 127 | install_db 128 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "awesomemotive/wp101plugin", 3 | "description": "A complete set of WordPress 101 tutorial videos directly within the dashboard. Choose which videos to show, or add your own!", 4 | "type": "wordpress-plugin", 5 | "homepage": "https://wp101plugin.com", 6 | "authors": [ 7 | { 8 | "name": "Shawn Hesketh", 9 | "homepage": "https://www.wp101.com" 10 | }, 11 | { 12 | "name": "Liquid Web", 13 | "homepage": "https://www.liquidweb.com" 14 | }, 15 | { 16 | "name": "Mark Jaquith", 17 | "homepage": "https://coveredwebservices.com" 18 | }, 19 | { 20 | "name": "Pippin Williamson", 21 | "homepage": "https://pippinsplugins.com" 22 | }, 23 | { 24 | "name": "Justin Sainton", 25 | "homepage": "https://zao.is" 26 | }, 27 | { 28 | "name": "Travis Smith", 29 | "homepage": "https://wpsmith.net" 30 | }, 31 | { 32 | "name": "John Sundberg", 33 | "homepage": "http://blackhillswebworks.com" 34 | } 35 | ], 36 | "support": { 37 | "issues": "https://github.com/awesomemotive/wp101plugin/issues", 38 | "forum": "https://wordpress.org/support/plugin/wp101", 39 | "source": "https://github.com/awesomemotive/wp101plugin", 40 | "docs": "https://wp101plugin.com" 41 | }, 42 | "require": {}, 43 | "require-dev": { 44 | "php": "^7.4", 45 | "dealerdirect/phpcodesniffer-composer-installer": "^1.0", 46 | "mockery/mockery": "^1.6", 47 | "phpcompatibility/php-compatibility": "^9.3", 48 | "phpunit/phpunit": "^9.0", 49 | "stevegrunwell/phpunit-markup-assertions": "^1.4", 50 | "stevegrunwell/runkit7-installer": "^1.2.0", 51 | "wp-coding-standards/wpcs": "^3.0" 52 | }, 53 | "config": { 54 | "preferred-install": "dist", 55 | "sort-packages": true, 56 | "optimize-autoloader": true, 57 | "platform": { 58 | "php": "7.4" 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /includes/addons.php: -------------------------------------------------------------------------------- 1 | has_api_key() ) { 24 | return; 25 | } 26 | 27 | $api = TemplateTags\api(); 28 | $addons = $api->get_addons(); 29 | $available = []; 30 | 31 | foreach ( $addons['addons'] as $series ) { 32 | if ( empty( $series['restrictions']['plugins'] ) ) { 33 | continue; 34 | } 35 | 36 | foreach ( $series['restrictions']['plugins'] as $plugin ) { 37 | if ( in_array( $plugin, $plugins, true ) ) { 38 | $available[ $series['slug'] ] = [ 39 | 'title' => $series['title'], 40 | 'url' => $series['url'], 41 | 'plugin' => $plugin, 42 | ]; 43 | } 44 | } 45 | } 46 | 47 | // Filter out any purchased add-ons. 48 | if ( ! empty( $available ) ) { 49 | $purchased = wp_list_pluck( $api->get_playlist()['series'], 'slug', 'slug' ); 50 | $available = array_diff_key( $available, $purchased ); 51 | } 52 | 53 | update_option( 'wp101-available-series', $available, false ); 54 | } 55 | add_action( 'update_option_active_plugins', __NAMESPACE__ . '\check_plugins', 10, 2 ); 56 | 57 | /** 58 | * In the administration area, alert users who are capable of purchasing add-ons to any new series 59 | * that may be of interest. 60 | * 61 | * @param WP_Screen $screen The current WP admin screen. 62 | */ 63 | function show_notifications( $screen ) { 64 | $screens = [ 'plugins', 'toplevel_page_wp101', 'video-tutorials_page_wp101-settings' ]; 65 | 66 | if ( ! in_array( $screen->id, $screens, true ) || ! current_user_can( Admin\get_addon_capability() ) ) { 67 | return; 68 | } 69 | 70 | // Filter out items that have been dismissed and/or purchased. 71 | $ignore = array_merge( 72 | (array) get_user_meta( get_current_user_id(), 'wp101-dismissed-notifications', true ), 73 | wp_list_pluck( TemplateTags\api()->get_playlist()['series'], 'slug', 'slug' ) 74 | ); 75 | 76 | $available = array_diff_key( 77 | get_option( 'wp101-available-series', [] ), 78 | array_fill_keys( $ignore, '' ) 79 | ); 80 | 81 | // Abort if we have nothing to say. 82 | if ( empty( $available ) ) { 83 | return; 84 | } 85 | 86 | // Register the callback to render the notification. 87 | add_action( 88 | 'admin_notices', 89 | function () use ( $available ) { 90 | $links = []; 91 | 92 | foreach ( (array) $available as $addon ) { 93 | $links[] = sprintf( '%2$s', $addon['url'], $addon['title'] ); 94 | } 95 | 96 | // Flatten the $links array into a single string. 97 | if ( 1 === count( $links ) ) { 98 | $link = array_shift( $links ); 99 | } else { 100 | $and = array_pop( $links ); 101 | $link = implode( _x( ', ', 'separator for multiple series in a sentence', 'wp101' ), $links ); 102 | if ( 2 <= count( $links ) ) { 103 | $link .= _x( ',', 'Oxford comma', 'wp101' ); 104 | } 105 | $link .= _x( ' and ', 'separator between the last two items in a list', 'wp101' ) . $and; 106 | } 107 | 108 | wp_enqueue_script( 'wp101-addons' ); 109 | 110 | render_notification( 111 | sprintf( 112 | /* Translators: %1$s is the add-on title(s). */ 113 | __( 'Would you like to add the tutorial videos for %1$s from WP101?', 'wp101' ), 114 | $link 115 | ), 116 | array_keys( $available ) 117 | ); 118 | } 119 | ); 120 | } 121 | add_action( 'current_screen', __NAMESPACE__ . '\show_notifications' ); 122 | 123 | /** 124 | * Render a notification based on the WordPress standards. 125 | * 126 | * @param string $message The unescaped message contents. 127 | * @param array $slug An array of one or more add-on slugs, to be flattened into a data attribute. 128 | */ 129 | function render_notification( $message, $slug ) { 130 | // phpcs:disable Generic.WhiteSpace.ScopeIndent.IncorrectExact 131 | ?> 132 | 133 |
134 |

135 |
136 | 137 | wp_create_nonce( 'dismiss-notice' ), 157 | ] 158 | ); 159 | } 160 | add_action( 'admin_enqueue_scripts', __NAMESPACE__ . '\register_scripts' ); 161 | 162 | /** 163 | * Ajax handler for dismissal of add-on notices. 164 | */ 165 | function dismiss_notice() { 166 | if ( ! isset( $_POST['addons'], $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'dismiss-notice' ) ) { 167 | wp_send_json_error(); 168 | } 169 | 170 | $user_id = get_current_user_id(); 171 | $dismissed = array_filter( 172 | array_merge( 173 | (array) get_user_meta( $user_id, 'wp101-dismissed-notifications', true ), 174 | (array) $_POST['addons'] 175 | ) 176 | ); 177 | 178 | update_user_meta( $user_id, 'wp101-dismissed-notifications', array_unique( array_values( $dismissed ) ) ); 179 | 180 | wp_send_json_success(); 181 | } 182 | add_action( 'wp_ajax_wp101_dismiss_notice', __NAMESPACE__ . '\dismiss_notice' ); 183 | -------------------------------------------------------------------------------- /includes/admin.php: -------------------------------------------------------------------------------- 1 | get_addons(); 94 | // 95 | // if ( ! empty( $addons['addons'] ) ) { 96 | // add_submenu_page( 97 | // 'wp101', 98 | // _x( 'WP101 Add-ons', 'page title', 'wp101' ), 99 | // _x( 'Add-ons', 'menu title', 'wp101' ), 100 | // get_addon_capability(), 101 | // 'wp101-addons', 102 | // __NAMESPACE__ . '\render_addons_page' 103 | // ); 104 | // } 105 | } 106 | 107 | add_action( 'admin_menu', __NAMESPACE__ . '\register_menu_pages' ); 108 | 109 | /** 110 | * Add a link to the WP101 plugin settings page on the plugin page. 111 | * 112 | * @param array $links Links currently being displayed for this plugin. 113 | * 114 | * @return array The filtered $links array. 115 | */ 116 | function plugin_settings_link( $links ) { 117 | $links['settings'] = sprintf( 118 | '%2$s', 119 | get_admin_url( null, 'admin.php?page=wp101-settings' ), 120 | _x( 'Settings', 'plugin links', 'wp101' ) 121 | ); 122 | 123 | return $links; 124 | } 125 | 126 | add_action( 'plugin_action_links_' . WP101_BASENAME, __NAMESPACE__ . '\plugin_settings_link' ); 127 | 128 | /** 129 | * Register the settings within WordPress. 130 | */ 131 | function register_settings() { 132 | register_setting( 133 | 'wp101', 134 | 'wp101_api_key', 135 | [ 136 | 'description' => _x( 'The key used to authenticate with WP101plugin.com.', 'wp101' ), 137 | 'sanitize_callback' => __NAMESPACE__ . '\sanitize_api_key', 138 | 'show_in_rest' => false, 139 | ] 140 | ); 141 | } 142 | 143 | add_action( 'admin_init', __NAMESPACE__ . '\register_settings' ); 144 | 145 | /** 146 | * Sanitize callback for the wp101_api_key setting. 147 | * 148 | * @param string $key The provided API key. 149 | * 150 | * @return string The sanitized key. 151 | */ 152 | function sanitize_api_key( $key ) { 153 | static $sanitized_api_key; 154 | 155 | $key = sanitize_text_field( $key ); 156 | 157 | // Simply return the key if it's already been sanitized once. 158 | if ( true === $sanitized_api_key ) { 159 | return $key; 160 | } 161 | 162 | // Ensure this won't be run in its entirety a second time. 163 | $sanitized_api_key = true; 164 | 165 | // Verify the API key against the API. 166 | $api = TemplateTags\api(); 167 | $api->set_api_key( $key ); 168 | 169 | // If the key is valid, inform the user. 170 | if ( $api->get_account() ) { 171 | add_settings_error( 172 | 'wp101', 173 | 'api_key', 174 | sprintf( 175 | 176 | /* 177 | * Translators: %1$s is a confirmation message, %2$s is the playlist page URL, and %3$s 178 | * is the link anchor text. 179 | */ 180 | '%1$s %3$s', 181 | esc_html__( 'Your API key ready to go:', 'wp101' ), 182 | esc_attr( get_admin_url( null, 'admin.php?page=wp101' ) ), 183 | esc_html__( 'start watching video tutorials!', 'wp101' ) 184 | ), 185 | 'updated' 186 | ); 187 | } else { 188 | add_settings_error( 'wp101', 'api_key', __( 'This API key is either invalid or has reached its maximum number of domains.', 'wp101' ), 'error' ); 189 | $key = ''; 190 | } 191 | 192 | return $key; 193 | } 194 | 195 | /** 196 | * Render the WP101 add-ons page. 197 | */ 198 | function render_addons_page() { 199 | $api = TemplateTags\api(); 200 | $addons = $api->get_addons(); 201 | $purchased = wp_list_pluck( $api->get_playlist()['series'], 'slug' ); 202 | 203 | include WP101_VIEWS . '/add-ons.php'; 204 | } 205 | 206 | /** 207 | * Render the WP101 listings page. 208 | */ 209 | function render_listings_page() { 210 | $api = TemplateTags\api(); 211 | $playlist = $api->get_playlist(); 212 | 213 | // Filter out irrelevant series. 214 | $playlist['series'] = array_filter( $playlist['series'], __NAMESPACE__ . '\is_relevant_series' ); 215 | 216 | include WP101_VIEWS . '/listings.php'; 217 | } 218 | 219 | /** 220 | * Render the WP101 settings page. 221 | */ 222 | function render_settings_page() { 223 | include WP101_VIEWS . '/settings.php'; 224 | } 225 | 226 | /** 227 | * Flush the public key after saving the private key. 228 | */ 229 | function clear_public_api_key() { 230 | delete_transient( API::get_instance()->get_public_api_key_name() ); 231 | 232 | // Prime the cache with the new public + private keys. 233 | $api = TemplateTags\api(); 234 | $api->clear_api_key(); 235 | $api->get_public_api_key(); 236 | } 237 | 238 | add_action( 'update_option_wp101_api_key', __NAMESPACE__ . '\clear_public_api_key' ); 239 | 240 | /** 241 | * Determine whether or not a series is relevant to the current site. 242 | * 243 | * Relevancy is determined based on two factors: 244 | * 245 | * 1. Does the series define any specific requirements? 246 | * 2. If so, does this site meet those requirements? 247 | * 248 | * For example, a series about Jetpack might specify that it should only be displayed on sites 249 | * running Jetpack — if a site doesn't have Jetpack installed and activated, don't bother 250 | * displaying the series. 251 | * 252 | * @param array $series The series object, as returned from the API. 253 | * 254 | * @return bool Whether or not the series should be displayed. 255 | */ 256 | function is_relevant_series( $series ) { 257 | if ( ! isset( $series['restrictions']['plugins'] ) || empty( $series['restrictions']['plugins'] ) ) { 258 | return true; 259 | } 260 | 261 | $restrictions = array_filter( $series['restrictions']['plugins'], 'is_plugin_active' ); 262 | 263 | return ! empty( $restrictions ); 264 | } 265 | 266 | /** 267 | * Inject admin notices from the API into WP Admin, but only on WP101 pages. 268 | */ 269 | function display_api_errors() { 270 | $api = TemplateTags\api(); 271 | $skip = [ 272 | 'wp101-no-api-key', 273 | ]; 274 | 275 | foreach ( $api->get_errors() as $error ) { 276 | if ( in_array( $error->get_error_code(), $skip, true ) ) { 277 | continue; 278 | } 279 | 280 | // phpcs:disable Generic.WhiteSpace.ScopeIndent.IncorrectExact 281 | ?> 282 | 283 |
284 |

get_error_message() ); ?>

285 |
286 | 287 | api_key ) { 96 | return $this->api_key; 97 | } 98 | 99 | if ( defined( 'WP101_API_KEY' ) ) { 100 | $this->api_key = WP101_API_KEY; 101 | } else { 102 | $this->api_key = get_option( self::API_KEY_OPTION, null ); 103 | } 104 | 105 | return $this->api_key; 106 | } 107 | 108 | /** 109 | * Explicitly set the API key. 110 | * 111 | * @param string $key The API key to use. 112 | */ 113 | public function set_api_key( $key ) { 114 | $this->api_key = $key; 115 | } 116 | 117 | /** 118 | * Clear the current value for $this->api_key. 119 | */ 120 | public function clear_api_key() { 121 | $this->api_key = null; 122 | } 123 | 124 | /** 125 | * Retrieve any API errors that have occurred. 126 | * 127 | * @return array An array of WP_Error objects. 128 | */ 129 | public function get_errors() { 130 | return $this->errors; 131 | } 132 | 133 | /** 134 | * Retrieve an *uncached* response from the /portal endpoint. 135 | * 136 | * @return array An array of all account attributes or an empty array if no account was found. 137 | */ 138 | public function get_account() { 139 | $response = $this->send_request( 'GET', '/portal' ); 140 | 141 | if ( is_wp_error( $response ) ) { 142 | return []; 143 | } 144 | 145 | return $response; 146 | } 147 | 148 | /** 149 | * Retrieve the public API key from WP101. 150 | * 151 | * Public API keys are generated on a per-domain basis by the WP101 API, and thus are safe for 152 | * using client-side without fear of compromising the private key. 153 | * 154 | * @return string|WP_Error The public API key or any WP_Error that occurred. 155 | */ 156 | public function get_public_api_key() { 157 | $key_name = $this->get_public_api_key_name(); 158 | $public_key = get_transient( $key_name ); 159 | 160 | if ( $public_key ) { 161 | return $public_key; 162 | } 163 | 164 | $response = $this->send_request( 'GET', '/portal' ); 165 | 166 | if ( is_wp_error( $response ) ) { 167 | return $response; 168 | } 169 | 170 | if ( empty( $response['publicKey'] ) ) { 171 | return new WP_Error( 'missing-public-key', __( 'Unable to retrieve a valid public key from WP101.' ) ); 172 | } 173 | 174 | $public_key = $response['publicKey']; 175 | 176 | set_transient( $key_name, $public_key, 0 ); 177 | 178 | return $public_key; 179 | } 180 | 181 | /** 182 | * Get the public API key name. 183 | * 184 | * The name consists of a static prefix followed by the first 8 characters of an md5 hash of 185 | * the site URL. 186 | * 187 | * @return string 188 | */ 189 | public function get_public_api_key_name() { 190 | return 'wp101-public-api-key-' . substr( md5( site_url( '/' ) ), 0, 8 ); 191 | } 192 | 193 | /** 194 | * Retrieve all available add-ons for WP101. 195 | * 196 | * @return array An array of all available add-ons. 197 | */ 198 | public function get_addons() { 199 | $response = $this->send_request( 'GET', '/add-ons', [], [], 12 * HOUR_IN_SECONDS ); 200 | 201 | if ( is_wp_error( $response ) ) { 202 | $this->handle_error( $response ); 203 | 204 | return [ 205 | 'addons' => [], 206 | ]; 207 | } 208 | 209 | // Catch responses that don't contain an array of add-ons. 210 | if ( ! isset( $response['addons'] ) ) { 211 | return [ 212 | 'addons' => [], 213 | ]; 214 | } 215 | 216 | // Append the public API key to add-on URLs. 217 | $api_key = $this->get_public_api_key(); 218 | 219 | array_walk( 220 | $response['addons'], 221 | function ( &$addon ) use ( $api_key ) { 222 | $addon['url'] = add_query_arg( 'apiKey', $api_key, $addon['url'] ); 223 | $addon['meets_requirements'] = true; 224 | 225 | // Does the current site configuration meet requirements? 226 | if ( ! empty( $addon['restrictions']['plugins'] ) ) { 227 | $requirements = array_filter( $addon['restrictions']['plugins'], 'is_plugin_active' ); 228 | 229 | $addon['meets_requirements'] = ! empty( $requirements ); 230 | } 231 | } 232 | ); 233 | 234 | return $response; 235 | } 236 | 237 | /** 238 | * Retrieve all series available to the user, based on API key. 239 | * 240 | * @return array An array of all available series and topics. 241 | */ 242 | public function get_playlist() { 243 | $response = $this->send_request( 'GET', '/videos', [], [], MINUTE_IN_SECONDS ); 244 | 245 | if ( is_wp_error( $response ) || ! isset( $response['series'] ) ) { 246 | if ( is_wp_error( $response ) ) { 247 | $this->handle_error( $response ); 248 | } 249 | 250 | return [ 251 | 'series' => [], 252 | ]; 253 | } 254 | 255 | /** 256 | * Filter the topics that should be displayed in the playlist. 257 | * 258 | * @param array $excluded An array of topic slugs and/or legacy IDs that should be excluded 259 | * from display in the playlist. 260 | */ 261 | $excluded = apply_filters( 'wp101_excluded_topics', [] ); 262 | 263 | if ( ! empty( $excluded ) ) { 264 | foreach ( $response['series'] as $key => $series ) { 265 | $response['series'][ $key ]['topics'] = array_filter( 266 | $series['topics'], 267 | function ( $topic ) use ( $excluded ) { 268 | return ! in_array( $topic['slug'], $excluded, true ) 269 | && ! in_array( $topic['legacy_id'], $excluded, true ); 270 | } 271 | ); 272 | } 273 | } 274 | 275 | return $response; 276 | } 277 | 278 | /** 279 | * Retrieve a single series by its slug. 280 | * 281 | * @param string $series The series slug. 282 | * 283 | * @return array|bool The series array for the given slug, or false if the given series was not 284 | * found in the API-provided playlist. 285 | */ 286 | public function get_series( $series ) { 287 | $playlist = $this->get_playlist(); 288 | 289 | // Iterate through the series and their topics to find a match. 290 | foreach ( (array) $playlist['series'] as $single_series ) { 291 | if ( $series === $single_series['slug'] ) { 292 | return $single_series; 293 | } 294 | } 295 | 296 | return false; 297 | } 298 | 299 | /** 300 | * Retrieve a single topic by its slug. 301 | * 302 | * @param string $topic The topic slug. 303 | * 304 | * @return array|bool The topic array for the given slug, or false if the given topic was not 305 | * found in the API-provided playlist. 306 | */ 307 | public function get_topic( $topic ) { 308 | $playlist = $this->get_playlist(); 309 | 310 | // Iterate through the series and their topics to find a match. 311 | foreach ( (array) $playlist['series'] as $series ) { 312 | foreach ( $series['topics'] as $single_topic ) { 313 | if ( $topic === $single_topic['slug'] ) { 314 | return $single_topic; 315 | } 316 | } 317 | } 318 | 319 | return false; 320 | } 321 | 322 | /** 323 | * Determine if an API key has been set. 324 | * 325 | * @return bool 326 | */ 327 | public function has_api_key() { 328 | return (bool) $this->get_api_key(); 329 | } 330 | 331 | /** 332 | * Determine if the current account has the given capability. 333 | * 334 | * @param string $cap The capability to check. 335 | * 336 | * @return bool Whether or not the user's account has the given capability. 337 | */ 338 | public function account_can( $cap ) { 339 | $response = $this->send_request( 'GET', '/portal', [], [], 12 * HOUR_IN_SECONDS ); 340 | 341 | if ( is_wp_error( $response ) ) { 342 | return false; 343 | } 344 | 345 | return isset( $response['capabilities'] ) && in_array( $cap, (array) $response['capabilities'], true ); 346 | } 347 | 348 | /** 349 | * Exchange a legacy API key for a 5.x API key. 350 | */ 351 | public function exchange_api_key() { 352 | $api_key = $this->get_api_key(); 353 | 354 | if ( empty( $api_key ) ) { 355 | return new WP_Error( 'wp101-api', __( 'Cannot exchange an empty API key.', 'wp101' ) ); 356 | } 357 | 358 | $response = wp_remote_post( 359 | $this->build_uri( '/key-exchange' ), 360 | [ 361 | 'timeout' => 30, 362 | 'user-agent' => self::USER_AGENT, 363 | 'body' => [ 364 | 'apiKey' => $api_key, 365 | 'domain' => site_url(), 366 | 367 | /** 368 | * Pass along custom topics to the key exchange, enabling these to be created 369 | * within WP101 automatically. 370 | * 371 | * @param array $custom_topics An array of custom WP101 topics. 372 | * 373 | * @deprecated 5.0.0 374 | * 375 | */ 376 | 'customTopics' => apply_filters( 'wp101_get_custom_help_topics', get_option( 'wp101_custom_topics' ) ), 377 | 378 | /** 379 | * Filter legacy WP101 topic IDs. 380 | * 381 | * This filter was available in WP101 4.x and below, and is only being applied so 382 | * that hidden topics are preserved during the API key exchange process. 383 | * 384 | * @param array $topic_ids An array of WP101 topics that should be hidden. 385 | * 386 | * @deprecated 5.0.0 387 | * 388 | */ 389 | 'hiddenTopics' => apply_filters( 'wp101_get_hidden_topics', get_option( 'wp101_hidden_topics' ) ), 390 | ], 391 | ] 392 | ); 393 | 394 | if ( is_wp_error( $response ) ) { 395 | return $response; 396 | } 397 | 398 | $response_code = wp_remote_retrieve_response_code( $response ); 399 | 400 | if ( ! in_array( $response_code, [ 200, 201 ], true ) ) { 401 | return new WP_Error( 402 | 'wp101-api', 403 | __( 'The WP101 API request failed.', 'wp101' ), 404 | $response 405 | ); 406 | } 407 | 408 | $body = json_decode( wp_remote_retrieve_body( $response ), true ); 409 | 410 | if ( 'fail' === $body['status'] ) { 411 | return new WP_Error( 412 | 'wp101-api', 413 | __( 'The WP101 API request failed.', 'wp101' ), 414 | $body['data'] 415 | ); 416 | } 417 | 418 | return $body['data']; 419 | } 420 | 421 | /** 422 | * Build an API request URI. 423 | * 424 | * @param string $path Optional. The API endpoint. Default is '/'. 425 | * @param array $args Optional. Query string arguments for the URI. Default is empty. 426 | * 427 | * @return string The URI for the API request. 428 | */ 429 | protected function build_uri( $path = '/', array $args = [] ) { 430 | $base = defined( 'WP101_API_URL' ) ? WP101_API_URL : self::API_URL; 431 | 432 | // Ensure the $path has a leading slash. 433 | if ( '/' !== substr( $path, 0, 1 ) ) { 434 | $path = '/' . $path; 435 | } 436 | 437 | return add_query_arg( $args, $base . $path ); 438 | } 439 | 440 | /** 441 | * Send a request to the WP101 API. 442 | * 443 | * @param string $method The HTTP method. 444 | * @param string $path The API request path. 445 | * @param array $query Optional. Query string arguments. Default is empty. 446 | * @param array $args Optional. Additional HTTP arguments. For a full list of options, 447 | * see wp_remote_request(). 448 | * @param int $cache Optional. The number of seconds for which the result should be cached. 449 | * Default is 0 seconds (no caching). 450 | * 451 | * @return array|WP_Error The HTTP response body or a WP_Error object if something went wrong. 452 | */ 453 | protected function send_request( $method, $path, $query = [], $args = [], $cache = 0 ) { 454 | $api_key = $this->get_api_key(); 455 | 456 | if ( empty( $api_key ) ) { 457 | return new WP_Error( 458 | 'wp101-no-api-key', 459 | __( 'No API key has been set, unable to make request.', 'wp101' ) 460 | ); 461 | } 462 | 463 | $uri = $this->build_uri( $path, $query ); 464 | $args = wp_parse_args( 465 | $args, 466 | [ 467 | 'timeout' => 30, 468 | 'user-agent' => self::USER_AGENT, 469 | 'headers' => [ 470 | 'Authorization' => 'Bearer ' . $api_key, 471 | 'Method' => $method, 472 | 'X-User-Domain' => site_url(), 473 | ], 474 | ] 475 | ); 476 | $cache_key = self::generate_cache_key( $uri, $args ); 477 | $cached = get_transient( $cache_key ); 478 | 479 | // Return the cached version, if we have it. 480 | if ( $cache && $cached ) { 481 | return $cached; 482 | } 483 | 484 | $response = wp_remote_request( $uri, $args ); 485 | 486 | if ( is_wp_error( $response ) ) { 487 | return $response; 488 | } 489 | 490 | $body = json_decode( wp_remote_retrieve_body( $response ), true ); 491 | 492 | if ( ! isset( $body['status'], $body['data'] ) ) { 493 | return new WP_Error( 494 | 'wp101-api', 495 | __( 'The WP101 API request response was invalid.', 'wp101' ), 496 | $body['data'] 497 | ); 498 | } 499 | 500 | if ( 'fail' === $body['status'] ) { 501 | return new WP_Error( 502 | 'wp101-api', 503 | /* Translators: %1$s is the first error message from the API response. */ 504 | sprintf( __( 'The WP101 API request failed: %1$s', 'wp101' ), current( (array) $body['data'] ) ), 505 | $body['data'] 506 | ); 507 | } 508 | 509 | // Cache the result. 510 | if ( $cache ) { 511 | set_transient( $cache_key, $body['data'], $cache ); 512 | } 513 | 514 | return $body['data']; 515 | } 516 | 517 | /** 518 | * Trigger an error and optionally block subsequent API requests. 519 | * 520 | * @param WP_Error $error The WP_Error object. 521 | */ 522 | protected function handle_error( $error ) { 523 | $this->errors[ $error->get_error_code() ] = $error; 524 | } 525 | 526 | /** 527 | * Given a URI and arguments, generate a cache key for use with WP101's internal caching system. 528 | * 529 | * @param string $uri The API URI, with any query string arguments. 530 | * @param array $args Optional. An array of HTTP arguments used in the request. Default is empty. 531 | * 532 | * @return string A cache key. 533 | */ 534 | public static function generate_cache_key( $uri, $args = [] ) { 535 | return 'wp101_' . substr( md5( $uri . wp_json_encode( $args ) ), 0, 12 ); 536 | } 537 | } 538 | -------------------------------------------------------------------------------- /includes/class-wp101-plugin.php: -------------------------------------------------------------------------------- 1 | '5.0.0', 29 | 'wp101_after_edit_custom_help_topics' => '5.0.0', 30 | 'wp101_after_help_topics' => '5.0.0', 31 | 'wp101_after_custom_help_topics' => '5.0.0', 32 | 'wp101_admin_action_add-video' => '5.0.0', 33 | 'wp101_admin_action_update-video' => '5.0.0', 34 | 'wp101_admin_action_restrict-admin' => '5.0.0', 35 | 'wp101_pre_includes' => '5.0.0', 36 | ]; 37 | 38 | foreach ( $deprecated as $hook => $version ) { 39 | if ( has_action( $hook ) ) { 40 | mark_deprecated( 'Action ' . $hook, "The {$hook} hook has been deprecated.", $version ); 41 | } 42 | } 43 | } 44 | add_action( 'init', __NAMESPACE__ . '\discover_deprecated_actions' ); 45 | 46 | /** 47 | * Check for anything hooked into deprecated filters. 48 | */ 49 | function discover_deprecated_filters() { 50 | $deprecated = [ 51 | 'wp101_default_settings_role' => '5.0.0', 52 | 'wp101_too_many_admins' => '5.0.0', 53 | 'wp101_settings_management_user_args' => '5.0.0', 54 | 'wp101_get_document' => '5.0.0', 55 | 'wp101_get_help_topics' => '5.0.0', 56 | 'wp101_get_custom_help_topics' => '5.0.0', 57 | 'wp101_get_hidden_topics' => '5.0.0', 58 | ]; 59 | 60 | foreach ( $deprecated as $hook => $version ) { 61 | if ( has_filter( $hook ) ) { 62 | mark_deprecated( 'Filter ' . $hook, "The {$hook} hook has been deprecated.", $version ); 63 | } 64 | } 65 | } 66 | add_action( 'init', __NAMESPACE__ . '\discover_deprecated_filters' ); 67 | -------------------------------------------------------------------------------- /includes/migrate.php: -------------------------------------------------------------------------------- 1 | get_api_key(); 20 | 21 | // Empty key is set via constant. 22 | if ( defined( 'WP101_API_KEY' ) && ! WP101_API_KEY ) { 23 | add_action( 'admin_notices', __NAMESPACE__ . '\render_constant_empty_notice' ); 24 | 25 | return; 26 | } 27 | 28 | // Schedule additional migrations if this is a multisite network. 29 | if ( is_multisite() && is_super_admin() && ! get_site_option( 'wp101-bulk-migration-lock', false ) ) { 30 | if ( ! wp_next_scheduled( 'wp101-bulk-migration' ) ) { 31 | wp_schedule_single_event( time(), 'wp101-bulk-migration' ); 32 | } 33 | 34 | add_site_option( 'wp101-bulk-migration-lock', true ); 35 | } 36 | 37 | // Key is either empty or already good to go. 38 | if ( ! api_key_needs_migration( $key ) ) { 39 | return; 40 | } 41 | 42 | $key = $api->exchange_api_key(); 43 | 44 | if ( is_wp_error( $key ) ) { 45 | // phpcs:disable WordPress.PHP.DevelopmentFunctions.error_log_trigger_error 46 | trigger_error( esc_html( $key->get_error_message() ), E_USER_WARNING ); 47 | // phpcs:enable 48 | 49 | $api->set_api_key( null ); 50 | 51 | add_action( 'admin_notices', __NAMESPACE__ . '\render_migration_failure_notice' ); 52 | 53 | return $key; 54 | } 55 | 56 | update_option( 'wp101_api_key', $key['apiKey'], false ); 57 | $api->set_api_key( $key['apiKey'] ); 58 | 59 | // Display a notice if the wp-config.php file needs updating. 60 | if ( defined( 'WP101_API_KEY' ) ) { 61 | add_action( 'admin_notices', __NAMESPACE__ . '\render_constant_upgrade_notice' ); 62 | } else { 63 | add_action( 'admin_notices', __NAMESPACE__ . '\render_migration_success_notice' ); 64 | } 65 | 66 | // Clean up old data. 67 | delete_option( 'wp101_custom_topics' ); 68 | delete_option( 'wp101_hidden_topics' ); 69 | } 70 | add_action( 'wp101_migrate_site', __NAMESPACE__ . '\maybe_migrate' ); 71 | 72 | /** 73 | * Determine whether the API key option requires migration. 74 | * 75 | * @param string $api_key Optional. The current API key to evaluate. Default is null. 76 | * 77 | * @return bool Whether or not the API key needs exchanged. 78 | */ 79 | function api_key_needs_migration( $api_key ) { 80 | return $api_key && 32 !== mb_strlen( $api_key ); 81 | } 82 | 83 | /** 84 | * Notify the user if the WP101_API_KEY constant in wp-config.php requires updating. 85 | * 86 | * If the constant is undefined, this will always return false. 87 | * 88 | * @return bool Whether or not the WP101_API_KEY constant requires updating. 89 | */ 90 | function wp_config_requires_updating() { 91 | if ( ! defined( 'WP101_API_KEY' ) ) { 92 | return false; 93 | } 94 | 95 | return ! WP101_API_KEY || api_key_needs_migration( WP101_API_KEY ); 96 | } 97 | 98 | /** 99 | * Display a notification when an automatic migration has succeeded. 100 | */ 101 | function render_migration_success_notice() { 102 | // phpcs:disable Generic.WhiteSpace.ScopeIndent.IncorrectExact 103 | ?> 104 | 105 |
106 |

107 |
108 | 109 | 118 | 119 |
120 |

121 |
122 | 123 | 132 | 133 |
134 |

135 |
    136 |
  1. 137 |
  2. 138 | define( 'WP101_API_KEY', '%s' );", WP101_API_KEY ) 144 | ) 145 | ); 146 | ?> 147 |
    define( 'WP101_API_KEY', '' );
    148 |
  3. 149 |
  4. 150 |
151 | 152 | 161 | 162 |
163 |

WP101_API_KEY constant has been defined in wp-config.php and should be removed:', 'wp101' ) ); ?>

164 |
    165 |
  1. 166 |
  2. 167 | define( 'WP101_API_KEY', '%s' );", WP101_API_KEY ) 173 | ) 174 | ); 175 | ?> 176 |
  3. 177 |
  4. 178 |
179 | 180 | 'ids', 202 | 'site__not_in' => [ get_current_blog_id() ], 203 | 'number' => $batch_size, 204 | 'offset' => 0, 205 | ]; 206 | $blogs = get_sites( $site_args ); 207 | 208 | // Iterate over the sites, triggering migrations. 209 | while ( ! empty( $blogs ) ) { 210 | $blog = array_shift( $blogs ); 211 | 212 | switch_to_blog( $blog ); 213 | 214 | // Ensure each site is loading its API key fresh. 215 | $api->clear_api_key(); 216 | 217 | // Trigger a migration. 218 | $migration = maybe_migrate(); 219 | 220 | // If we ran into an issue, remove the lock so it can try again. 221 | if ( is_wp_error( $migration ) ) { 222 | delete_site_option( 'wp101-bulk-migration-lock' ); 223 | 224 | return $migrated; 225 | } 226 | 227 | // Restore the previous site context. 228 | restore_current_blog(); 229 | 230 | // Increment the counter. 231 | $migrated++; 232 | 233 | // Reset the query at the end of the batch. 234 | if ( empty( $blogs ) ) { 235 | $site_args['offset'] = $migrated; 236 | 237 | $blogs = get_sites( $site_args ); 238 | } 239 | } 240 | 241 | return $migrated; 242 | } 243 | add_action( 'wp101-bulk-migration', __NAMESPACE__ . '\migrate_multisite' ); 244 | -------------------------------------------------------------------------------- /includes/shortcode.php: -------------------------------------------------------------------------------- 1 | null, 42 | 'video' => null, 43 | ], 44 | $atts, 45 | 'wp101' 46 | ); 47 | $api = TemplateTags\api(); 48 | 49 | if ( ! $api->account_can( 'embed-on-front-end' ) ) { 50 | return shortcode_debug( __( 'Your WP101 subscription does not permit embedding on the front-end of a site.', 'wp101' ) ); 51 | } 52 | 53 | // Load the requisite files. 54 | wp_enqueue_style( 'wp101' ); 55 | 56 | if ( $atts['course'] ) { 57 | $series = TemplateTags\get_series( $atts['course'] ); 58 | 59 | if ( false === $series ) { 60 | return shortcode_debug( 61 | sprintf( 62 | /* Translators: %1$s is the series slug. */ 63 | __( 'Course "%1$s" was not found.', 'wp101' ), 64 | $atts['course'] 65 | ) 66 | ); 67 | } 68 | 69 | return render_shortcode_playlist( $series ); 70 | 71 | } elseif ( $atts['video'] ) { 72 | $topic = TemplateTags\get_topic( $atts['video'] ); 73 | 74 | if ( false === $topic ) { 75 | return shortcode_debug( 76 | sprintf( 77 | /* Translators: %1$s is the video slug. */ 78 | __( 'Video "%1$s" was not found.', 'wp101' ), 79 | $atts['video'] 80 | ) 81 | ); 82 | } 83 | 84 | return render_shortcode_single( $topic ); 85 | } 86 | 87 | return shortcode_debug( __( 'No WP101 courses or video were specified.', 'wp101' ) ); 88 | } 89 | add_shortcode( 'wp101', __NAMESPACE__ . '\render_shortcode' ); 90 | 91 | /** 92 | * Render the shortcode for a single topic. 93 | * 94 | * Note that this function should not be called directly, but through render_shortcode(). 95 | * 96 | * @param array $topic The topic to display. 97 | * 98 | * @return string The rendered shortcode content. 99 | */ 100 | function render_shortcode_single( $topic ) { 101 | $query_args = [ 102 | 'apiKey' => TemplateTags\api()->get_public_api_key(), 103 | 'host' => site_url(), 104 | ]; 105 | 106 | ob_start(); 107 | // phpcs:disable Generic.WhiteSpace.ScopeIndent.IncorrectExact 108 | ?> 109 | 110 |
111 |
112 | 113 |
114 |
115 |

116 | 117 | 118 | 119 |
120 |
121 | 122 | TemplateTags\api()->get_public_api_key(), 146 | 'host' => site_url(), 147 | ]; 148 | 149 | ob_start(); 150 | // phpcs:disable Generic.WhiteSpace.ScopeIndent.IncorrectExact 151 | ?> 152 | 153 |
154 |
155 |

156 |
157 |
158 | 159 | 160 |
161 |
162 | 163 |
164 |
165 |

166 |
167 |
168 | 169 | 170 |
171 |
172 | 173 | ', esc_html( $message ) ); 197 | } 198 | 199 | return $output; 200 | } 201 | -------------------------------------------------------------------------------- /includes/template-tags.php: -------------------------------------------------------------------------------- 1 | api()->get_public_api_key(), 38 | // ] 39 | // ); 40 | } 41 | add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\enqueue_scripts_styles' ); 42 | add_action( 'admin_enqueue_scripts', __NAMESPACE__ . '\enqueue_scripts_styles' ); 43 | 44 | /** 45 | * Grant access to the API Singleton. 46 | * 47 | * @return API The API Singleton instance. 48 | */ 49 | function api() { 50 | return API::get_instance(); 51 | } 52 | 53 | /** 54 | * Shortcut for retrieving the current API key. 55 | * 56 | * @see WP101\API::get_api_key() 57 | * 58 | * @return string The current API key, or an empty string if one is not set. 59 | */ 60 | function get_api_key() { 61 | return api()->get_api_key(); 62 | } 63 | 64 | /** 65 | * Determine if the current user can purchase add-ons. 66 | * 67 | * @return bool 68 | */ 69 | function current_user_can_purchase_addons() { 70 | return current_user_can( Admin\get_addon_capability() ); 71 | } 72 | 73 | /** 74 | * Retrieve a series by its slug. 75 | * 76 | * @param string $slug The series slug. 77 | * 78 | * @return array|bool Either the corresponding series array or a boolean false if either the API 79 | * key doesn't have access to the series or the series doesn't exist. 80 | */ 81 | function get_series( $slug ) { 82 | return api()->get_series( $slug ); 83 | } 84 | 85 | /** 86 | * Retrieve a topic by its slug. 87 | * 88 | * @param string $slug The topic slug. 89 | * 90 | * @return array|bool Either the corresponding topic array or a boolean false if either the API key 91 | * doesn't have access to the topic or the topic doesn't exist. 92 | */ 93 | function get_topic( $slug ) { 94 | return api()->get_topic( $slug ); 95 | } 96 | 97 | /** 98 | * Loop over a list of videos, optionally truncating to the first $limit topics. 99 | * 100 | * @param array $topics The topics within the add-on. 101 | * @param int $limit Optional. The maximum number of topics to show. Default is 0 (all). 102 | * @param string $link Optional. A URL to link the "and X more!" string to. Default is null. 103 | */ 104 | function list_topics( $topics, $limit = 0, $link = null ) { 105 | $counter = 0; 106 | $items = []; 107 | 108 | foreach ( $topics as $topic ) { 109 | $counter++; 110 | 111 | // Append the list item. 112 | $items[] = sprintf( '
  • %s
  • ', esc_html( $topic['title'] ) ); 113 | 114 | // We've reached our limit. 115 | if ( $limit <= $counter ) { 116 | $remaining = count( $topics ) - $counter; 117 | 118 | if ( 1 > $remaining ) { 119 | continue; 120 | 121 | } elseif ( 1 === $remaining ) { 122 | $label = __( '…and one more video!', 'wp101' ); 123 | 124 | } else { 125 | /* Translators: %1$d is the number of videos in the series not shown. */ 126 | $label = sprintf( __( '… and %1$d more videos!', 'wp101' ), $remaining ); 127 | } 128 | 129 | if ( $link ) { 130 | $items[] = sprintf( 131 | '
  • %2$s
  • ', 132 | esc_url( $link ), 133 | esc_html( $label ) 134 | ); 135 | 136 | } else { 137 | $items[] = sprintf( '
  • %s
  • ', esc_html( $label ) ); 138 | } 139 | 140 | break; 141 | } 142 | } 143 | 144 | if ( ! empty( $items ) ) { 145 | echo wp_kses_post( 146 | sprintf( 147 | '
      %s
    ', 148 | implode( '', $items ) 149 | ) 150 | ); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /includes/uninstall.php: -------------------------------------------------------------------------------- 1 | \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: en\n" 15 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 16 | "X-Poedit-Country: United States\n" 17 | "X-Poedit-SourceCharset: UTF-8\n" 18 | "X-Poedit-KeywordsList: " 19 | "__;_e;_x:1,2c;_ex:1,2c;_n:1,2;_nx:1,2,4c;_n_noop:1,2;_nx_noop:1,2,3c;esc_" 20 | "attr__;esc_html__;esc_attr_e;esc_html_e;esc_attr_x:1,2c;esc_html_x:1,2c;\n" 21 | "X-Poedit-Basepath: ../\n" 22 | "X-Poedit-SearchPath-0: .\n" 23 | "X-Poedit-Bookmarks: \n" 24 | "X-Textdomain-Support: yes\n" 25 | "X-Generator: grunt-wp-i18n 1.0.3\n" 26 | 27 | #: includes/addons.php:113 28 | #. Translators: %1$s is the add-on title(s). 29 | msgid "Would you like to add the tutorial videos for %1$s from WP101?" 30 | msgstr "" 31 | 32 | #: includes/admin.php:192 33 | #. Translators: %1$s is a confirmation message, %2$s is the playlist page URL, 34 | #. and %3$s is the link anchor text. 35 | msgid "Your API key ready to go:" 36 | msgstr "" 37 | 38 | #: includes/admin.php:194 39 | msgid "start watching video tutorials!" 40 | msgstr "" 41 | 42 | #: includes/admin.php:199 43 | msgid "This API key is either invalid or has reached its maximum number of domains." 44 | msgstr "" 45 | 46 | #: includes/class-api.php:167 47 | msgid "Unable to retrieve a valid public key from WP101." 48 | msgstr "" 49 | 50 | #: includes/class-api.php:349 51 | msgid "Cannot exchange an empty API key." 52 | msgstr "" 53 | 54 | #: includes/class-api.php:395 includes/class-api.php:405 55 | msgid "The WP101 API request failed." 56 | msgstr "" 57 | 58 | #: includes/class-api.php:450 59 | msgid "No API key has been set, unable to make request." 60 | msgstr "" 61 | 62 | #: includes/class-api.php:487 63 | #. Translators: %1$s is the first error message from the API response. 64 | msgid "The WP101 API request failed: %1$s" 65 | msgstr "" 66 | 67 | #: includes/migrate.php:106 68 | msgid "" 69 | "WP101 has automatically upgraded your API key to work with the latest " 70 | "version!" 71 | msgstr "" 72 | 73 | #: includes/migrate.php:120 74 | msgid "" 75 | "WP101 was unable to automatically migrate your API key for the latest " 76 | "version." 77 | msgstr "" 78 | 79 | #: includes/migrate.php:134 80 | msgid "" 81 | "Your API key has been updated to work with the latest version of WP101, but " 82 | "your wp-config.php requires updating:" 83 | msgstr "" 84 | 85 | #: includes/migrate.php:136 includes/migrate.php:165 86 | msgid "Open your site's wp-config.php file in a text editor." 87 | msgstr "" 88 | 89 | #: includes/migrate.php:142 90 | #. Translators: %1$s is a code snippet for "define( 'WP101_API_KEY', '...' );" 91 | #. in wp-config.php. 92 | msgid "" 93 | "Find the line that reads %1$s and either remove it completely or replace it " 94 | "with the following:" 95 | msgstr "" 96 | 97 | #: includes/migrate.php:149 includes/migrate.php:177 98 | msgid "Save the wp-config.php file on your web server." 99 | msgstr "" 100 | 101 | #: includes/migrate.php:163 102 | msgid "" 103 | "An empty WP101_API_KEY constant has been defined in " 104 | "wp-config.php and should be removed:" 105 | msgstr "" 106 | 107 | #: includes/migrate.php:171 108 | #. Translators: %1$s is a code snippet for "define( 'WP101_API_KEY', '...' );" 109 | #. in wp-config.php. 110 | msgid "" 111 | "Find the line that reads %1$s and either remove it completely or set the " 112 | "value to your WP101 Plugin API key." 113 | msgstr "" 114 | 115 | #: includes/shortcode.php:50 116 | msgid "" 117 | "Your WP101 subscription does not permit embedding on the front-end of a " 118 | "site." 119 | msgstr "" 120 | 121 | #: includes/shortcode.php:63 122 | #. Translators: %1$s is the series slug. 123 | msgid "Course \"%1$s\" was not found." 124 | msgstr "" 125 | 126 | #: includes/shortcode.php:78 127 | #. Translators: %1$s is the video slug. 128 | msgid "Video \"%1$s\" was not found." 129 | msgstr "" 130 | 131 | #: includes/shortcode.php:87 132 | msgid "No WP101 courses or video were specified." 133 | msgstr "" 134 | 135 | #: includes/template-tags.php:121 136 | msgid "…and one more video!" 137 | msgstr "" 138 | 139 | #: includes/template-tags.php:125 140 | #. Translators: %1$d is the number of videos in the series not shown. 141 | msgid "… and %1$d more videos!" 142 | msgstr "" 143 | 144 | #: views/add-ons.php:26 145 | msgid "There are no add-ons currently available for WP101!" 146 | msgstr "" 147 | 148 | #: views/add-ons.php:31 149 | msgid "Enhance your WP101 experience with these add-ons:" 150 | msgstr "" 151 | 152 | #: views/add-ons.php:41 153 | msgid "Subscribed" 154 | msgstr "" 155 | 156 | #: views/add-ons.php:51 157 | msgid "In this series:" 158 | msgstr "" 159 | 160 | #: views/add-ons.php:60 161 | #. Translators: %1$s is the add-on name. 162 | msgid "Watch %1$s Videos" 163 | msgstr "" 164 | 165 | #: views/add-ons.php:67 166 | msgid "" 167 | "Your WP101 Plugin subscription includes access to this course, but it looks " 168 | "like it might not be useful on this site." 169 | msgstr "" 170 | 171 | #: views/add-ons.php:75 172 | #. Translators: %1$s is the add-on name. 173 | msgid "Get the %1$s Add-on" 174 | msgstr "" 175 | 176 | #: views/listings.php:54 177 | msgid "More from WP101" 178 | msgstr "" 179 | 180 | #: views/listings.php:55 181 | msgid "Get the most out of WP101 with even more content!" 182 | msgstr "" 183 | 184 | #: views/listings.php:56 185 | msgid "Get more videos from WP101" 186 | msgstr "" 187 | 188 | #: views/listings.php:64 189 | msgid "There was a problem retrieving content from WP101plugin.com." 190 | msgstr "" 191 | 192 | #: views/listings.php:71 193 | #. Translators: %1$s is the "WP101 Settings" admin page. 194 | msgid "" 195 | "Please verify your API key and ensure your " 196 | "WP101plugin.com account has access to the desired content." 197 | msgstr "" 198 | 199 | #: views/listings.php:76 200 | msgid "Please contact a site administrator for further assistance." 201 | msgstr "" 202 | 203 | #: views/settings.php:35 204 | msgid "" 205 | "Your API key enables your WordPress site to connect to WP101 and retrieve " 206 | "all of your videos." 207 | msgstr "" 208 | 209 | #: views/settings.php:36 210 | msgid "Don't have an API key?" 211 | msgstr "" 212 | 213 | #: views/settings.php:37 214 | msgid "Get your key now!" 215 | msgstr "" 216 | 217 | #: views/settings.php:42 218 | msgid "Your API key is defined in your wp-config.php file." 219 | msgstr "" 220 | 221 | #: views/settings.php:43 222 | msgid "" 223 | "To make changes, please open your wp-config.php file in a text editor and " 224 | "look for the line that includes:" 225 | msgstr "" 226 | 227 | #: views/settings.php:55 views/settings.php:73 228 | msgid "API key" 229 | msgstr "" 230 | 231 | #: views/settings.php:78 232 | msgid "Replace my API Key" 233 | msgstr "" 234 | 235 | #. Author of the plugin/theme 236 | msgid "WP101" 237 | msgstr "" 238 | 239 | #. Plugin URI of the plugin/theme 240 | msgid "https://wp101plugin.com" 241 | msgstr "" 242 | 243 | #. Description of the plugin/theme 244 | msgid "" 245 | "A complete set of video tutorials for WordPress, Jetpack, WooCommerce, and " 246 | "Yoast SEO, delivered directly in the dashboard." 247 | msgstr "" 248 | 249 | #. Author URI of the plugin/theme 250 | msgid "https://wp101.com" 251 | msgstr "" 252 | 253 | #: includes/addons.php:101 254 | msgctxt "separator for multiple series in a sentence" 255 | msgid ", " 256 | msgstr "" 257 | 258 | #: includes/addons.php:103 259 | msgctxt "Oxford comma" 260 | msgid "," 261 | msgstr "" 262 | 263 | #: includes/addons.php:105 264 | msgctxt "separator between the last two items in a list" 265 | msgid " and " 266 | msgstr "" 267 | 268 | #: includes/admin.php:80 includes/admin.php:90 269 | msgctxt "page title" 270 | msgid "WP101" 271 | msgstr "" 272 | 273 | #: includes/admin.php:100 274 | msgctxt "page title" 275 | msgid "WP101 Settings" 276 | msgstr "" 277 | 278 | #: includes/admin.php:112 279 | msgctxt "page title" 280 | msgid "WP101 Add-ons" 281 | msgstr "" 282 | 283 | #: includes/admin.php:81 includes/admin.php:91 284 | msgctxt "menu title" 285 | msgid "Video Tutorials" 286 | msgstr "" 287 | 288 | #: includes/admin.php:101 289 | msgctxt "menu title" 290 | msgid "Settings" 291 | msgstr "" 292 | 293 | #: includes/admin.php:113 294 | msgctxt "menu title" 295 | msgid "Add-ons" 296 | msgstr "" 297 | 298 | #: includes/admin.php:133 299 | msgctxt "plugin links" 300 | msgid "Settings" 301 | msgstr "" 302 | 303 | #: includes/admin.php:148 304 | msgctxt "wp101" 305 | msgid "The key used to authenticate with WP101plugin.com." 306 | msgstr "" 307 | 308 | #: views/add-ons.php:18 309 | msgctxt "listings page title" 310 | msgid "WP101 Add-ons" 311 | msgstr "" 312 | 313 | #: views/listings.php:23 314 | msgctxt "listings page title" 315 | msgid "WordPress Video Tutorials" 316 | msgstr "" 317 | 318 | #: views/settings.php:30 319 | msgctxt "settings page title" 320 | msgid "WP101 Settings" 321 | msgstr "" 322 | 323 | #: views/settings.php:34 324 | msgctxt "settings section heading" 325 | msgid "WP101Plugin.com API Key" 326 | msgstr "" -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wp101", 3 | "description": "A complete set of WordPress video tutorials for beginners, delivered directly in the dashboard.", 4 | "main": "Gruntfile.js", 5 | "author": { 6 | "name": "WP101", 7 | "url": "https://wp101.com" 8 | }, 9 | "contributors": [ 10 | { 11 | "name": "Liquid Web", 12 | "url": "https://www.liquidweb.com" 13 | }, 14 | { 15 | "name": "Steve Grunwell", 16 | "url": "https://stevegrunwell.com" 17 | } 18 | ], 19 | "private": true, 20 | "devDependencies": { 21 | "eslint-config-wordpress": "^2.0.0", 22 | "grunt-contrib-copy": "^1.0.0", 23 | "grunt-contrib-uglify": "^3.4.0", 24 | "grunt-eslint": "^20.2.0", 25 | "grunt-sass": "^2.1.0", 26 | "grunt-wp-i18n": "^1.0.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | Coding standards for WP101. 8 | 9 | 10 | 11 | . 12 | 13 | 14 | 15 | 16 | 17 | assets/* 18 | dist/* 19 | node_modules/* 20 | tests/* 21 | vendor/* 22 | 23 | 24 | integrations/* 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | * 33 | 34 | 35 | 36 | 37 | includes/class-* 38 | tests/* 39 | 40 | 41 | 42 | includes/class-wp101-plugin.php 43 | 44 | 45 | 49 | 50 | 0 51 | 52 | 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | tests/ 12 | 13 | 14 | 15 | 16 | includes 17 | wp101.php 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /plugin-repo-assets/banner-1544x500.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomemotive/wp101plugin/6a5276df5410efb921819bd0515b237edf322e4f/plugin-repo-assets/banner-1544x500.jpg -------------------------------------------------------------------------------- /plugin-repo-assets/banner-772x250.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomemotive/wp101plugin/6a5276df5410efb921819bd0515b237edf322e4f/plugin-repo-assets/banner-772x250.jpg -------------------------------------------------------------------------------- /plugin-repo-assets/icon-128x128.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomemotive/wp101plugin/6a5276df5410efb921819bd0515b237edf322e4f/plugin-repo-assets/icon-128x128.jpg -------------------------------------------------------------------------------- /plugin-repo-assets/icon-256x256.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomemotive/wp101plugin/6a5276df5410efb921819bd0515b237edf322e4f/plugin-repo-assets/icon-256x256.jpg -------------------------------------------------------------------------------- /plugin-repo-assets/screenshot-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomemotive/wp101plugin/6a5276df5410efb921819bd0515b237edf322e4f/plugin-repo-assets/screenshot-1.jpg -------------------------------------------------------------------------------- /plugin-repo-assets/screenshot-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomemotive/wp101plugin/6a5276df5410efb921819bd0515b237edf322e4f/plugin-repo-assets/screenshot-2.jpg -------------------------------------------------------------------------------- /plugin-repo-assets/screenshot-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomemotive/wp101plugin/6a5276df5410efb921819bd0515b237edf322e4f/plugin-repo-assets/screenshot-3.jpg -------------------------------------------------------------------------------- /plugin-repo-assets/screenshot-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomemotive/wp101plugin/6a5276df5410efb921819bd0515b237edf322e4f/plugin-repo-assets/screenshot-4.jpg -------------------------------------------------------------------------------- /plugin-repo-assets/screenshot-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomemotive/wp101plugin/6a5276df5410efb921819bd0515b237edf322e4f/plugin-repo-assets/screenshot-5.jpg -------------------------------------------------------------------------------- /plugin-repo-assets/screenshot-6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomemotive/wp101plugin/6a5276df5410efb921819bd0515b237edf322e4f/plugin-repo-assets/screenshot-6.jpg -------------------------------------------------------------------------------- /readme.txt: -------------------------------------------------------------------------------- 1 | === WP101 Video Tutorial Plugin === 2 | Contributors: shawndh, markjaquith, mordauk, JustinSainton, wpsmith, bhwebworks, liquidweb 3 | Tags: wp101, tutorials, video, help, learn 4 | Requires at least: 5.1 5 | Requires PHP: 7.4 6 | Tested up to: 6.7.1 7 | Stable tag: 5.4.1 8 | License: GPLv2 or later 9 | 10 | Professional video tutorials for WordPress, WooCommerce, Elementor, and more, right in the dashboard of your WordPress site. Perfect for beginners. 11 | 12 | == Description == 13 | 14 | The WP101® Video Tutorial Plugin is simply the easiest way to teach your clients WordPress basics, cutting your support costs while providing an invaluable resource for your clients. It delivers a library of professionally-produced, WordPress 101 tutorial videos directly within your client’s own dashboard. 15 | 16 | In addition to video tutorials for WordPress (both Gutenberg and Classic Editor), we're continually expanding our library with video tutorials for the most popular WordPress plugins, including WooCommerce, Elementor, Beaver Builder, Ninja Forms, and WPForms. 17 | 18 | Simply enter your [WP101Plugin.com](https://wp101plugin.com/) API key to display our WordPress tutorial videos within your client’s WordPress administration panel. 19 | 20 | You can choose which tutorial videos to show in the list, or even embed your own custom videos. 21 | 22 | Stop wasting your valuable time teaching WordPress to your clients. Let the WP101 Plugin free your time to do what you do best! 23 | 24 | == Installation == 25 | 26 | 1. Go to [WP101Plugin.com](https://wp101plugin.com/) to get your API key. 27 | 2. Copy your API key from your [WP101Plugin.com](https://app.wp101plugin.com/) dashboard. 28 | 3. Install and activate the WP101 Plugin in the 'Plugins' panel. 29 | 4. Go to the Video Tutorials menu item and click the Settings button to enter your API key. 30 | 31 | == Frequently Asked Questions == 32 | 33 | = How do I get an API key? = 34 | 35 | Simply go to: [WP101Plugin.com](https://wp101plugin.com/) and follow the instructions to set up an API key in less than a minute. 36 | 37 | = Can I choose which video topics are displayed? = 38 | 39 | Yes! You can selectively hide or show individual tutorial videos (or entire courses) through the app at [WP101Plugin.com](https://app.wp101plugin.com). 40 | 41 | = Can I add my own custom videos? = 42 | 43 | Yes! You can add your own custom videos, and they'll appear at the bottom of the list of tutorial videos. Visit the “Custom Videos” page in the [WP101 Plugin app](https://app.wp101plugin.com/custom-topics). 44 | 45 | = What if I have the Classic Editor installed? = 46 | 47 | If the Classic Editor plugin is also installed and activated on your site, the previous version of our WordPress 101 videos for the Classic Editor in WordPress 4.9 and older will also appear in the list. You can hide or show these videos in the Settings. 48 | 49 | = Why aren’t the videos for WooCommerce, WPForms, etc. showing up? = 50 | 51 | The tutorial videos for WooCommerce, Ninja Forms, WPForms and other plugins will only appear in the list if the plugin in question is also installed and activated on the same site. No sense showing videos that don’t apply to a particular site, right? 52 | 53 | = The plugin was installed by my developer, but their API key has expired. What do I do? = 54 | 55 | You can ask your developer to renew their subscription, or you can go to [WP101Plugin.com](https://wp101plugin.com/) to start your own Personal subscription and get access to all of our videos. 56 | 57 | = Can I hardcode my API key into the plugin for use across multiple installations? = 58 | 59 | Yes! Simply define the `WP101_API_KEY` constant within your `wp-config` file: 60 | 61 | /** 62 | * API key for the WP101 plugin. 63 | * 64 | * @link https://wp101plugin.com 65 | */ 66 | define( 'WP101_API_KEY', 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' ); 67 | 68 | == Screenshots == 69 | 70 | 1. The video tutorial selection and viewing interface. 71 | 2. Add your own custom videos and deploy them to one or more sites. 72 | 3. Choose to hide or show individual videos—or an entire series—on a per-domain basis. 73 | 4. Manage all your client domains remotely, from one dashboard. 74 | 5. With an Agency Plan, you can also embed the 31-part WordPress 101 video series on the front-end of your membership site. 75 | 6. Use one API key across all your sites, or generate unique API keys as needed. 76 | 77 | == Changelog == 78 | 79 | For a complete list of changes, please see [the plugin's GitHub repository](https://github.com/leftlane/wp101plugin/blob/master/CHANGELOG.md). 80 | 81 | = 5.4.1 = 82 | * Fix: Deprecated notice in PHP 8.1+ 83 | 84 | = 5.4.0 = 85 | * Tested and verified for WordPress 6.7.1 86 | * Fix: Major performance issues when the plugin is activated. 👈 87 | * Fix: The custom videos not showing up in the list of videos. 88 | * Replaced the old API endpoints with the new ones. 89 | * Code cleanup and other minor bug fixes. 90 | 91 | = 5.3.2 = 92 | * Tested and verified for WordPress 6.7.0 93 | * Changing the source of videos (internal) 94 | 95 | = 5.3.1 = 96 | * Tested and verified for WordPress 6.0.1 97 | * Resolved warning in PHP 8 98 | 99 | = 5.3 = 100 | * Tested and verified for WordPress 5.7. 101 | * Updated name for better discoverability. 102 | 103 | = 5.2 = 104 | * Tested and verified for WordPress 5.3. 105 | * Updated screenshots and WP101® branding. 106 | 107 | = 5.1.0 = 108 | * Run migrations across a multisite network via a background task. 109 | * Store public API keys based on the site URL, enabling better handling of domain changes. 110 | * Add the `wp101_excluded_topics` filter. 111 | 112 | = 5.0.1 = 113 | * Ensure that legacy API keys are exchanged before making any other API requests. 114 | 115 | = 5.0.0 = 116 | * Complete rewrite of the plugin and backing APIs to bring even more content to the WP101 plugin. 117 | * Custom videos, course visibility, and permissions are now controlled via [the WP101 Plugin app](https://app.wp101plugin.com). 118 | 119 | == Upgrade Notice == 120 | 121 | = 5.3.1 = 122 | * Tested and verified for WordPress 6.0.1 123 | * Resolved warning in PHP 8 124 | 125 | = 5.3 = 126 | * Tested and verified for WordPress 5.7, plus some minor housekeeping. 127 | 128 | = 5.2 = 129 | * Tested and verified for WordPress 5.3, plus some minor housekeeping. 130 | 131 | = 5.1.0 = 132 | * Improves migration behavior on WordPress multisite instances. 133 | 134 | = 5.0.1 = 135 | * Resolves an issue some subscribers were seeing during API key migration. 136 | 137 | = 5.0.0 = 138 | * We’ve completely redesigned the WP101 Plugin from the ground-up, adding brand new features and improving the entire experience. 139 | 140 | = 4.2.1 = 141 | * In addition to whether or not the Classic Editor plugin is installed and activated, this minor fix also checks to see if filters are being used to disable Gutenberg. If so, display the previous version of our WordPress 101 videos instead of the new videos for Gutenberg and 5.0. Thanks, Cliff Seal! 142 | 143 | = 4.2 = 144 | * Re-added the previous WordPress 101 videos for the Classic Editor in WordPress 4.9 or older, provided the Classic Editor plugin is also installed and activated. 145 | 146 | = 4.1 = 147 | * Brand new WordPress 101 video tutorial series, completely rewritten for the all-new Gutenberg Block Editor in WordPress 5.0! 148 | 149 | = 4.1 = 150 | * We’ve added videos for the MailPoet plugin, provided that plugin is installed and activated. 151 | 152 | = 4.0.2 = 153 | * Minor bug fix. 154 | 155 | = 4.0.1 = 156 | * Minor fix. 157 | 158 | = 4.0 = 159 | * This is a big one! The WP101 Plugin now includes videos for WooCommerce and Jetpack, provided those plugins are also installed. Plus a few more goodies. 160 | 161 | = 3.2.2 = 162 | * Minor changes to description verbiage and fixed a tiny typo. 163 | * Tested and verified for WordPress 4.3! 164 | 165 | = 3.2.1 = 166 | * Changed title to reflect the new name of the Yoast SEO plugin. 167 | 168 | = 3.2 = 169 | * We’ve updated the Yoast SEO plugin videos for version 2.0. 170 | * Tested and verified for WordPress 4.2! 171 | 172 | = 3.1 = 173 | * This important update adds the ability to limit access to the settings panel to a specific administrator, plus adds several new filters for this new feature. Thanks, Justin Sainton! 174 | * We’ve also assigned the plugin instance to a (global) variable, to make it accessible outside the plugin for modifications. Thanks, John Sundberg! 175 | 176 | = 3.0.4 = 177 | * Bug fixes for hiding and showing all the SEO videos. Thanks, Justin Sainton! 178 | 179 | = 3.0.3 = 180 | * Added more detailed docs on the built-in hooks to filter the list of videos, or even add your own! 181 | 182 | = 3.0 = 183 | * We’ve added videos for the Yoast SEO plugin, provided that plugin is installed. 184 | * Added new filters for developers. You can now filter the topics and videos returned on wp101_get_help_topics and wp101_get_custom_help_topics. 185 | * Increased the default size of the video player, plus added responsive support for all your devices! 186 | * Minor coding standards cleanup. 187 | 188 | = 2.1.1 = 189 | * Bug fix for missing wp101_icon_url error. 190 | 191 | = 2.1 = 192 | * Updated for WordPress 3.8, including new menu icon. 193 | 194 | = 2.0.6 = 195 | * Bug fix for missing api_key_notset_message. 196 | 197 | = 2.0.5 = 198 | * Bug fix to address issue when hiding the first video in the list. 199 | 200 | = 2.0.4 = 201 | * Minor changes to improve the white-labeled experience. 202 | 203 | = 2.0.3 = 204 | * Fix to ensure hardcoded API keys are not lost on upgrade. 205 | 206 | = 2.0.2 = 207 | * Bug fix to address "API key not valid" error on multisite installations. 208 | * Removed redundant notification when API key is not set. 209 | 210 | = 2.0.1 = 211 | * Minor fix to ensure the actively-playing video title is bold. 212 | 213 | = 2.0 = 214 | * Includes the ability to hide individual videos. 215 | * Includes he ability to add your own custom videos. 216 | * Compatibility with WordPress 3.4. 217 | 218 | = 1.1.1 = 219 | * Minor fix related to hardcoded API keys. Added a custom menu icon. 220 | 221 | = 1.1 = 222 | * Made API Key input field more secure, and a couple of widely-requested minor changes. 223 | 224 | = 1.0.1 = 225 | * Minor bug fix for multisite installations. 226 | 227 | = 1.0 = 228 | First version! 229 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | mock_api(); 20 | $api->shouldReceive( 'has_api_key' ) 21 | ->once() 22 | ->andReturn( true ); 23 | $api->shouldReceive( 'get_addons' ) 24 | ->once() 25 | ->andReturn( [ 26 | 'addons' => [ 27 | [ 28 | 'title' => 'Learning Some Plugin', 29 | 'slug' => 'learning-some-plugin', 30 | 'excerpt' => 'An excerpt', 31 | 'description' => 'The full description', 32 | 'url' => 'https://wp101plugin.com/series/some-plugin', 33 | 'restrictions' => [ 34 | 'plugins' => [ 35 | 'some-plugin/some-plugin.php', 36 | ], 37 | ], 38 | ], 39 | ], 40 | ] ); 41 | $api->shouldReceive( 'get_playlist' ) 42 | ->once() 43 | ->andReturn( [ 44 | 'series' => [], 45 | ] ); 46 | 47 | Addons\check_plugins( null, array( 48 | 'some-plugin/some-plugin.php', 49 | 'another-plugin/another-plugin.php', 50 | ) ); 51 | 52 | $this->assertEquals( [ 53 | 'learning-some-plugin' => [ 54 | 'title' => 'Learning Some Plugin', 55 | 'url' => 'https://wp101plugin.com/series/some-plugin', 56 | 'plugin' => 'some-plugin/some-plugin.php', 57 | ], 58 | ], get_option( 'wp101-available-series', [] ) ); 59 | } 60 | 61 | public function test_check_plugins_does_not_fire_if_no_api_key_is_set() { 62 | $api = $this->mock_api(); 63 | $api->shouldReceive( 'has_api_key' ) 64 | ->once() 65 | ->andReturn( false ); 66 | $api->shouldReceive( 'get_addons' ) 67 | ->never(); 68 | 69 | Addons\check_plugins( null, [ 70 | 'some-plugin/some-plugin.php', 71 | 'another-plugin/another-plugin.php', 72 | ] ); 73 | 74 | $this->assertEmpty( get_option( 'wp101-available-series', [] ) ); 75 | } 76 | 77 | public function test_check_plugins_filters_out_purchased_addons() { 78 | $api = $this->mock_api(); 79 | $api->shouldReceive( 'has_api_key' ) 80 | ->andReturn( true ); 81 | $api->shouldReceive( 'get_addons' ) 82 | ->andReturn( [ 83 | 'addons' => [ 84 | [ 85 | 'title' => 'Learning Some Plugin', 86 | 'slug' => 'learning-some-plugin', 87 | 'excerpt' => 'An excerpt', 88 | 'description' => 'The full description', 89 | 'url' => 'https://wp101plugin.com/series/some-plugin', 90 | 'restrictions' => [ 91 | 'plugins' => [ 92 | 'some-plugin/some-plugin.php', 93 | ], 94 | ], 95 | ], 96 | ], 97 | ] ); 98 | $api->shouldReceive( 'get_playlist' ) 99 | ->once() 100 | ->andReturn( [ 101 | 'series' => [ 102 | [ 103 | 'slug' => 'learning-some-plugin', 104 | ], 105 | ], 106 | ] ); 107 | 108 | Addons\check_plugins( null, [ 109 | 'some-plugin/some-plugin.php', 110 | ] ); 111 | 112 | $this->assertEmpty( get_option( 'wp101-available-series', [] ) ); 113 | } 114 | 115 | public function test_show_notifications() { 116 | Addons\register_scripts(); 117 | 118 | wp_set_current_user( $this->factory()->user->create( [ 119 | 'role' => 'administrator', 120 | ] ) ); 121 | 122 | update_option( 'wp101-available-series', [ 123 | 'learning-some-plugin' => [ 124 | 'title' => 'Learning Some Plugin', 125 | 'url' => '#', 126 | 'plugin' => 'some-plugin/some-plugin.php', 127 | ], 128 | ] ); 129 | 130 | ob_start(); 131 | Addons\show_notifications( WP_Screen::get( 'plugins' ) ); 132 | do_action( 'admin_notices' ); 133 | $output = ob_get_clean(); 134 | 135 | $this->assertContains( 'Learning Some Plugin', $output ); 136 | $this->assertContains( 'data-wp101-addon-slug="learning-some-plugin"', $output ); 137 | $this->assertTrue( wp_script_is( 'wp101-addons', 'enqueued' ) ); 138 | } 139 | 140 | /** 141 | * @dataProvider notification_page_provider() 142 | * 143 | * @param string $page The admin page screen's base. 144 | * @param bool $expected Should the notification appear on this page? 145 | */ 146 | public function test_show_notifications_only_shows_on_specific_pages( $page, $expected ) { 147 | wp_set_current_user( $this->factory()->user->create( [ 148 | 'role' => 'administrator', 149 | ] ) ); 150 | 151 | update_option( 'wp101-available-series', [ 152 | 'learning-some-plugin' => [ 153 | 'title' => 'Learning Some Plugin', 154 | 'url' => '#', 155 | 'plugin' => 'some-plugin/some-plugin.php', 156 | ], 157 | ] ); 158 | 159 | ob_start(); 160 | Addons\show_notifications( WP_Screen::get( $page ) ); 161 | do_action( 'admin_notices' ); 162 | $output = ob_get_clean(); 163 | 164 | if ( $expected ) { 165 | $this->assertNotEmpty( $output ); 166 | } else { 167 | $this->assertEmpty( $output ); 168 | } 169 | } 170 | 171 | /** 172 | * If an add-on has been subscribed to but the 'update_option_active_plugins' hasn't been 173 | * fired, the add-on may still exist in the 'wp101-available-series' option. 174 | * 175 | * @link https://github.com/liquidweb/wp101plugin/issues/70 176 | */ 177 | public function test_show_notifications_does_not_include_subscribed_addons() { 178 | $api = $this->mock_api(); 179 | $api->shouldReceive( 'has_api_key' ) 180 | ->andReturn( true ); 181 | $api->shouldReceive( 'get_playlist' ) 182 | ->once() 183 | ->andReturn( [ 184 | 'series' => [ 185 | [ 186 | 'slug' => 'learning-some-plugin', 187 | ], 188 | ], 189 | ] ); 190 | 191 | wp_set_current_user( $this->factory()->user->create( [ 192 | 'role' => 'administrator', 193 | ] ) ); 194 | 195 | update_option( 'wp101-available-series', [ 196 | 'learning-some-plugin' => [ 197 | 'title' => 'Learning Some Plugin', 198 | 'url' => '#', 199 | 'plugin' => 'some-plugin/some-plugin.php', 200 | ], 201 | ] ); 202 | 203 | ob_start(); 204 | Addons\show_notifications( WP_Screen::get( 'plugins' ) ); 205 | do_action( 'admin_notices' ); 206 | $output = ob_get_clean(); 207 | 208 | $this->assertEmpty( $output ); 209 | } 210 | 211 | public function notification_page_provider() { 212 | return [ 213 | 'Plugins page' => [ 'plugins', true ], 214 | 'WP101 player page' => [ 'toplevel_page_wp101', true ], 215 | 'WP101 settings page' => [ 'video-tutorials_page_wp101-settings', true ], 216 | 'Posts page' => [ 'edit', false ], 217 | 'Users page' => [ 'users', false ], 218 | ]; 219 | } 220 | 221 | public function test_show_notifications_checks_capabilities() { 222 | wp_set_current_user( $this->factory()->user->create( [ 223 | 'role' => 'subscriber', 224 | ] ) ); 225 | 226 | update_option( 'wp101-available-series', [ 227 | 'learning-some-plugin' => [ 228 | 'title' => 'Learning Some Plugin', 229 | 'url' => '#', 230 | 'plugin' => 'some-plugin/some-plugin.php', 231 | ], 232 | ] ); 233 | 234 | ob_start(); 235 | Addons\show_notifications( WP_Screen::get( 'plugins' ) ); 236 | do_action( 'admin_notices' ); 237 | $output = ob_get_clean(); 238 | 239 | $this->assertEmpty( $output ); 240 | } 241 | 242 | public function test_show_notifications_wont_show_dismissed_notifications() { 243 | wp_set_current_user( $this->factory()->user->create( [ 244 | 'role' => 'administrator', 245 | ] ) ); 246 | 247 | update_option( 'wp101-available-series', [ 248 | 'learning-some-plugin' => [ 249 | 'title' => 'Learning Some Plugin', 250 | 'url' => '#', 251 | 'plugin' => 'some-plugin/some-plugin.php', 252 | ], 253 | ] ); 254 | 255 | add_user_meta( get_current_user_id(), 'wp101-dismissed-notifications', [ 256 | 'learning-some-plugin', 257 | ] ); 258 | 259 | ob_start(); 260 | Addons\show_notifications( WP_Screen::get( 'plugins' ) ); 261 | do_action( 'admin_notices' ); 262 | $output = ob_get_clean(); 263 | 264 | $this->assertEmpty( $output ); 265 | } 266 | 267 | public function test_show_notifications_can_flatten_multiple_plugins() { 268 | wp_set_current_user( $this->factory()->user->create( [ 269 | 'role' => 'administrator', 270 | ] ) ); 271 | 272 | update_option( 'wp101-available-series', [ 273 | 'first-plugin' => [ 274 | 'title' => 'First plugin', 275 | 'url' => '#', 276 | 'plugin' => 'first-plugin/first-plugin.php', 277 | ], 278 | 'second-plugin' => [ 279 | 'title' => 'Second plugin', 280 | 'url' => '#', 281 | 'plugin' => 'second-plugin/second-plugin.php', 282 | ], 283 | 'third-plugin' => [ 284 | 'title' => 'Third plugin', 285 | 'url' => '#', 286 | 'plugin' => 'third-plugin/third-plugin.php', 287 | ], 288 | ] ); 289 | 290 | ob_start(); 291 | Addons\show_notifications( WP_Screen::get( 'plugins' ) ); 292 | do_action( 'admin_notices' ); 293 | $output = ob_get_clean(); 294 | 295 | $this->assertContains( 'First plugin, Second plugin, and Third plugin', strip_tags( $output ) ); 296 | $this->assertContains( 'data-wp101-addon-slug="first-plugin,second-plugin,third-plugin"', $output); 297 | } 298 | 299 | public function test_dismiss_notification() { 300 | add_filter( 'wp_die_ajax_handler', function () { 301 | return '__return_empty_string'; 302 | } ); 303 | 304 | wp_set_current_user( $this->factory()->user->create( [ 305 | 'role' => 'administrator', 306 | ] ) ); 307 | 308 | $_POST = [ 309 | 'addons' => [ 'foo', 'bar' ], 310 | 'nonce' => wp_create_nonce( 'dismiss-notice' ), 311 | ]; 312 | 313 | add_filter( 'wp_doing_ajax', '__return_true' ); 314 | 315 | ob_start(); 316 | Addons\dismiss_notice(); 317 | ob_end_clean(); 318 | 319 | $this->assertEquals( 320 | [ 'foo', 'bar' ], 321 | get_user_meta( get_current_user_id(), 'wp101-dismissed-notifications', true ) 322 | ); 323 | } 324 | 325 | public function test_dismiss_notification_merges_with_existing_settings() { 326 | add_filter( 'wp_die_ajax_handler', function () { 327 | return '__return_empty_string'; 328 | } ); 329 | 330 | wp_set_current_user( $this->factory()->user->create( [ 331 | 'role' => 'administrator', 332 | ] ) ); 333 | 334 | add_user_meta( get_current_user_id(), 'wp101-dismissed-notifications', [ 'foo' ] ); 335 | 336 | $_POST = [ 337 | 'addons' => [ 'bar', 'baz' ], 338 | 'nonce' => wp_create_nonce( 'dismiss-notice' ), 339 | ]; 340 | 341 | add_filter( 'wp_doing_ajax', '__return_true' ); 342 | 343 | ob_start(); 344 | Addons\dismiss_notice(); 345 | ob_end_clean(); 346 | 347 | $this->assertEquals( 348 | [ 'foo', 'bar', 'baz' ], 349 | get_user_meta( get_current_user_id(), 'wp101-dismissed-notifications', true ) 350 | ); 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /tests/test-admin.php: -------------------------------------------------------------------------------- 1 | assertFalse( wp_style_is( 'wp101-admin', 'registered' ) ); 21 | $this->assertFalse( wp_script_is( 'wp101-admin', 'registered' ) ); 22 | 23 | Admin\enqueue_scripts( 'some-hook' ); 24 | 25 | $this->assertTrue( 26 | wp_style_is( 'wp101-admin', 'registered' ), 27 | 'Calling register_scripts() should register the "wp101-admin" style.' 28 | ); 29 | $this->assertTrue( 30 | wp_script_is( 'wp101-admin', 'registered' ), 31 | 'Calling register_scripts() should register the "wp101-admin" script.' 32 | ); 33 | } 34 | 35 | /** 36 | * Ensure that WP101 assets are only enqueued on WP101 pages. 37 | * 38 | * @testWith ["some-hook", false] 39 | * ["wp101", true] 40 | * ["toplevel_page_wp101", true] 41 | * ["video-tutorials_page_wp101-settings", true] 42 | * ["video-tutorials_page_wp101-addons", true] 43 | * ["random-wp101", true] 44 | * 45 | * @param string $hook The hook to be executed. 46 | * @param bool $enqueued Whether or not the assets should be enqueued for this hook. 47 | */ 48 | public function test_enqueue_scripts_enqueues_on_wp101_pages( $hook, bool $enqueued ) { 49 | Admin\enqueue_scripts( $hook ); 50 | 51 | $this->assertSame( $enqueued, wp_style_is( 'wp101-admin', 'enqueued' ) ); 52 | $this->assertSame( $enqueued, wp_script_is( 'wp101-admin', 'enqueued' ) ); 53 | 54 | if ( $enqueued ) { 55 | $this->assertSame( 10, has_action( 'admin_notices', 'WP101\Admin\display_api_errors' ) ); 56 | } 57 | } 58 | 59 | public function test_get_addon_capability() { 60 | $this->assertEquals( 61 | 'publish_posts', 62 | Admin\get_addon_capability(), 63 | 'Default should be "publish_posts".' 64 | ); 65 | 66 | add_filter( 'wp101_addon_capability', function () { 67 | return 'my_capability'; 68 | } ); 69 | 70 | $this->assertEquals( 71 | 'my_capability', 72 | Admin\get_addon_capability(), 73 | 'Value should be overridden via the wp101_addon_capability filter.' 74 | ); 75 | } 76 | 77 | public function test_register_menu_pages() { 78 | wp_set_current_user( $this->factory()->user->create( [ 79 | 'role' => 'administrator', 80 | ] ) ); 81 | 82 | $api = $this->mock_api(); 83 | $api->shouldReceive( 'get_playlist' ) 84 | ->once() 85 | ->andReturn( [ 86 | 'series' => [ 87 | 'some series', 88 | ], 89 | ] ); 90 | $api->shouldReceive( 'get_addons' ) 91 | ->once() 92 | ->andReturn( [ 93 | 'addons' => [ 94 | [ 95 | 'slug' => 'some-add-on', 96 | ], 97 | ], 98 | ] ); 99 | 100 | $menu = $this->get_menu_items(); 101 | 102 | $this->assertEquals( 'wp101', $menu['parent'][2], 'Expected "wp101" as the menu slug.' ); 103 | $this->assertEquals( 'wp101', $menu['children'][0][2], 'The first child should be "wp101".' ); 104 | $this->assertEquals( 'manage_options', $menu['children'][1][1], 'The wp101-settings page requires manage_options.' ); 105 | $this->assertEquals( 'wp101-settings', $menu['children'][1][2], 'The second child should be "wp101-settings".' ); 106 | $this->assertEquals( 'wp101-addons', $menu['children'][2][2], 'The third child should be "wp101-addons".' ); 107 | 108 | // Ensure WordPress can generate corresponding menu page URLs. 109 | $this->assertNotEmpty( menu_page_url( 'wp101', false ) ); 110 | $this->assertNotEmpty( menu_page_url( 'wp101-settings', false ) ); 111 | $this->assertNotEmpty( menu_page_url( 'wp101-addons', false ) ); 112 | } 113 | 114 | /** 115 | * If an API key hasn't been set, only the WP101 Settings page should be shown. 116 | */ 117 | public function test_register_menu_pages_hides_listings_if_listings_are_available() { 118 | wp_set_current_user( $this->factory()->user->create( [ 119 | 'role' => 'administrator', 120 | ] ) ); 121 | 122 | $api = $this->mock_api(); 123 | $api->shouldReceive( 'get_playlist' ) 124 | ->once() 125 | ->andReturn( [ 126 | 'series' => [], 127 | ] ); 128 | 129 | $menu = $this->get_menu_items(); 130 | 131 | $this->assertEquals( 'wp101-settings', $menu['parent'][2], 'Expected "wp101-settings" as the menu slug.' ); 132 | } 133 | 134 | public function test_register_menu_pages_hides_empty_addon_pages() { 135 | wp_set_current_user( $this->factory()->user->create( [ 136 | 'role' => 'administrator', 137 | ] ) ); 138 | 139 | $api = $this->mock_api(); 140 | $api->shouldReceive( 'get_playlist' ) 141 | ->andReturn( [ 142 | 'series' => [ 143 | 'some series', 144 | ], 145 | ] ); 146 | $api->shouldReceive( 'get_addons' ) 147 | ->once() 148 | ->andReturn( [ 149 | 'addons' => [], 150 | ] ); 151 | 152 | $menu = $this->get_menu_items(); 153 | 154 | $this->assertCount( 2, $menu['children'] ); 155 | $this->assertEmpty( menu_page_url( 'wp101-addons', false ) ); 156 | } 157 | 158 | public function test_register_settings() { 159 | Admin\register_settings(); 160 | 161 | $settings = get_registered_settings(); 162 | 163 | $this->assertArrayHasKey( 'wp101_api_key', $settings ); 164 | $this->assertEquals( 'wp101', $settings['wp101_api_key']['group'] ); 165 | $this->assertEquals( 'WP101\Admin\sanitize_api_key', $settings['wp101_api_key']['sanitize_callback'] ); 166 | $this->assertFalse( $settings['wp101_api_key']['show_in_rest'], 'The API key should never be exposed via the WP REST API.' ); 167 | } 168 | 169 | /** 170 | * Work around the well-known "when storing a new setting, the sanitize callback filter is 171 | * is called twice" issue with the WordPress Settings API. 172 | * 173 | * @link https://developer.wordpress.org/reference/functions/register_setting/#comment-content-2488 174 | * @ticket https://github.com/liquidweb/wp101plugin/issues/73 175 | */ 176 | public function test_sanitize_api_key_is_only_called_once() { 177 | Admin\register_settings(); 178 | 179 | $api = $this->mock_api(); 180 | $api->shouldReceive( 'get_account' ) 181 | ->once() 182 | ->andReturn( [ 'some-account-details' ] ); 183 | 184 | // Call it twice, to mimic what we'd see when calling update_option() for a new option. 185 | sanitize_option( 'wp101_api_key', 'someKey' ); 186 | sanitize_option( 'wp101_api_key', 'someKey' ); 187 | 188 | ob_start(); 189 | settings_errors(); 190 | $output = ob_get_clean(); 191 | 192 | $this->assertSelectorCount( 1, '#setting-error-api_key', $output ); 193 | } 194 | 195 | public function test_settings_link_is_injected_into_plugin_action_links() { 196 | $actions = apply_filters( 'plugin_action_links_' . WP101_BASENAME, array() ); 197 | 198 | $this->assertContains( get_admin_url( null, 'admin.php?page=wp101' ), $actions['settings'] ); 199 | } 200 | 201 | public function test_is_relevant_series_without_restrictions() { 202 | $series = [ 203 | 'restrictions' => [ 204 | 'plugins' => [], 205 | ], 206 | ]; 207 | 208 | $this->assertTrue( 209 | Admin\is_relevant_series( $series ), 210 | 'Series without restrictions are always relevant.' 211 | ); 212 | } 213 | 214 | public function test_is_relevant_series_with_unmet_restrictions() { 215 | $series = [ 216 | 'restrictions' => [ 217 | 'plugins' => [ 218 | 'some-plugin/some-plugin.php', 219 | 'some-other-plugin/some-other-plugin.php', 220 | ], 221 | ], 222 | ]; 223 | 224 | update_option( 'active_plugins', [] ); 225 | 226 | $this->assertFalse( 227 | Admin\is_relevant_series( $series ), 228 | 'If requirements aren\'t met, the series is not relevant.' 229 | ); 230 | } 231 | 232 | public function test_is_relevant_series_with_some_met_restrictions() { 233 | $series = [ 234 | 'restrictions' => [ 235 | 'plugins' => [ 236 | 'some-plugin/some-plugin.php', 237 | 'some-other-plugin/some-other-plugin.php', 238 | ], 239 | ], 240 | ]; 241 | 242 | update_option( 'active_plugins', [ 243 | 'some-plugin/some-plugin.php', 244 | ] ); 245 | 246 | $this->assertTrue( 247 | Admin\is_relevant_series( $series ), 248 | 'Only meeting a single requirement is necessary for relevancy.' 249 | ); 250 | } 251 | 252 | public function test_is_relevant_series_with_all_met_restrictions() { 253 | $series = [ 254 | 'restrictions' => [ 255 | 'plugins' => [ 256 | 'some-plugin/some-plugin.php', 257 | 'some-other-plugin/some-other-plugin.php', 258 | ], 259 | ], 260 | ]; 261 | 262 | update_option( 'active_plugins', [ 263 | 'some-plugin/some-plugin.php', 264 | 'some-other-plugin/some-other-plugin.php', 265 | ] ); 266 | 267 | $this->assertTrue( Admin\is_relevant_series( $series ) ); 268 | } 269 | 270 | public function test_is_relevant_series_can_be_overridden_via_filter() { 271 | $series = [ 272 | 'restrictions' => [ 273 | 'plugins' => [ 274 | 'some-plugin/some-plugin.php', 275 | ], 276 | ], 277 | ]; 278 | 279 | update_option( 'active_plugins', [] ); 280 | 281 | add_filter( 'wp101_is_relevant_series', '__return_true' ); 282 | 283 | $this->assertTrue( Admin\is_relevant_series( $series ) ); 284 | } 285 | 286 | /** 287 | * @link https://github.com/liquidweb/wp101plugin/issues/40 288 | */ 289 | public function test_render_addons_page_indicates_when_available_series_are_visible() { 290 | $api = $this->mock_api(); 291 | $api->shouldReceive( 'get_playlist' ) 292 | ->andReturn( [ 293 | 'series' => [ 294 | [ 295 | 'slug' => 'example-series', 296 | ], 297 | ], 298 | ] ); 299 | $api->shouldReceive( 'get_addons' ) 300 | ->andReturn( [ 301 | 'addons' => [ 302 | [ 303 | 'title' => 'Example Series', 304 | 'slug' => 'example-series', 305 | 'meets_requirements' => true, 306 | ], 307 | ] 308 | ] ); 309 | 310 | ob_start(); 311 | Admin\render_addons_page(); 312 | $output = ob_get_clean(); 313 | 314 | $this->assertContains( get_admin_url( null, 'admin.php?page=wp101' ), $output ); 315 | } 316 | 317 | /** 318 | * @link https://github.com/liquidweb/wp101plugin/issues/40 319 | */ 320 | public function test_render_addons_page_indicates_when_available_series_are_hidden() { 321 | $api = $this->mock_api(); 322 | $api->shouldReceive( 'get_playlist' ) 323 | ->andReturn( [ 324 | 'series' => [ 325 | [ 326 | 'slug' => 'example-series', 327 | ], 328 | ], 329 | ] ); 330 | $api->shouldReceive( 'get_addons' ) 331 | ->andReturn( [ 332 | 'addons' => [ 333 | [ 334 | 'title' => 'Example Series', 335 | 'slug' => 'example-series', 336 | 'meets_requirements' => false, 337 | ], 338 | ] 339 | ] ); 340 | 341 | ob_start(); 342 | Admin\render_addons_page(); 343 | $output = ob_get_clean(); 344 | 345 | $this->assertElementNotContains( 346 | get_admin_url( null, 'admin.php?page=wp101' ), 347 | '.wp101-addon-button a', 348 | $output 349 | ); 350 | $this->assertSelectorCount( 1, '.wp101-addon .notice-info', $output ); 351 | } 352 | 353 | /** 354 | * @testWith ["wp101-no-api-key"] 355 | * @link https://github.com/liquidweb/wp101plugin/issues/67 356 | */ 357 | public function test_special_wp_errors_are_skipped_in_error_output( $code ) { 358 | $api = API::get_instance(); 359 | $method = $this->get_accessible_method( $api, 'handle_error' ); 360 | $method->invoke( $api, new WP_Error( $code, 'My error message' ) ); 361 | 362 | ob_start(); 363 | Admin\display_api_errors(); 364 | $output = ob_get_clean(); 365 | 366 | $this->assertNotContains( 'My error message', $output ); 367 | } 368 | 369 | /** 370 | * Retrieve the WP101 menu node(s) visible for the current user. 371 | * 372 | * @return array 373 | */ 374 | protected function get_menu_items() { 375 | global $menu, $submenu; 376 | 377 | do_action( 'admin_menu' ); 378 | 379 | return [ 380 | 'parent' => $menu[0], 381 | 'children' => isset( $submenu['wp101'] ) ? $submenu['wp101'] : [], 382 | ]; 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /tests/test-api.php: -------------------------------------------------------------------------------- 1 | set_api_key( 'default-api-key' ); 23 | } 24 | 25 | public function tearDown() { 26 | parent::tearDown(); 27 | 28 | // Clean up after any send_request() tests. 29 | remove_all_filters( 'pre_http_request' ); 30 | } 31 | 32 | public function test_get_api_key_returns_from_cache() { 33 | $api = API::get_instance(); 34 | $key = md5( uniqid() ); 35 | 36 | $prop = new \ReflectionProperty( $api, 'api_key' ); 37 | $prop->setAccessible( true ); 38 | $prop->setValue( $api, $key ); 39 | 40 | $this->assertEquals( $key, $api->get_api_key() ); 41 | } 42 | 43 | /** 44 | * @requires extension runkit 45 | */ 46 | public function test_get_api_key_reads_constant() { 47 | define( 'WP101_API_KEY', md5( uniqid() ) ); 48 | 49 | $this->assertEquals( WP101_API_KEY, API::get_instance()->get_api_key() ); 50 | } 51 | 52 | public function test_get_api_key_reads_from_options() { 53 | $key = md5( uniqid() ); 54 | $this->set_api_key( $key ); 55 | 56 | $this->assertFalse( 57 | defined( 'WP101_API_KEY' ), 58 | 'This test is predicated on the WP101_API_KEY constant not being set.' 59 | ); 60 | 61 | $this->assertEquals( $key, API::get_instance()->get_api_key() ); 62 | } 63 | 64 | public function test_set_api_key() { 65 | $api = API::get_instance(); 66 | $key = md5( uniqid() ); 67 | 68 | $api->set_api_key( $key ); 69 | 70 | $this->assertEquals( $key, $api->get_api_key() ); 71 | } 72 | 73 | public function test_clear_api_key() { 74 | $api = API::get_instance(); 75 | $prop = new \ReflectionProperty( $api, 'api_key' ); 76 | $prop->setAccessible( true ); 77 | 78 | $api->set_api_key( md5( uniqid() ) ); 79 | $api->clear_api_key(); 80 | 81 | $this->assertNull( $prop->getValue( $api ) ); 82 | } 83 | 84 | public function test_has_api_key() { 85 | $api = API::get_instance(); 86 | 87 | delete_option( 'wp101_api_key' ); 88 | 89 | $this->assertFalse( $api->has_api_key() ); 90 | 91 | $this->set_api_key( 'some-api-key' ); 92 | 93 | $this->assertTrue( $api->has_api_key() ); 94 | } 95 | 96 | public function test_get_errors() { 97 | $errors = [ 'foo' => new WP_Error( 'foo', uniqid() ) ]; 98 | $api = API::get_instance(); 99 | $prop = new \ReflectionProperty( $api, 'errors' ); 100 | $prop->setAccessible( true ); 101 | $prop->setValue( $api, $errors ); 102 | 103 | $this->assertSame( $errors, $api->get_errors() ); 104 | } 105 | 106 | public function test_get_account() { 107 | $json = [ 108 | 'status' => 'success', 109 | 'data' => [ 110 | 'publicKey' => uniqid(), 111 | ], 112 | ]; 113 | 114 | $this->set_expected_response( [ 115 | 'body' => wp_json_encode( $json ), 116 | ] ); 117 | 118 | $this->assertEquals( 119 | $json['data'], 120 | API::get_instance()->get_account(), 121 | 'The account node should be returned.' 122 | ); 123 | } 124 | 125 | public function test_get_account_suppresses_wp_errors() { 126 | $this->set_expected_response( function () { 127 | return new WP_Error( 'msg' ); 128 | } ); 129 | 130 | $this->assertEmpty( 131 | API::get_instance()->get_account(), 132 | 'If an error occurs, get_account() should return an empty array.' 133 | ); 134 | } 135 | 136 | public function test_get_public_api_key() { 137 | $api = API::get_instance(); 138 | $name = $api->get_public_api_key_name(); 139 | 140 | $this->assertFalse( get_transient( $name ) ); 141 | 142 | $json = [ 143 | 'status' => 'success', 144 | 'data' => [ 145 | 'publicKey' => uniqid(), 146 | ], 147 | ]; 148 | 149 | $this->set_expected_response( [ 150 | 'body' => wp_json_encode( $json ), 151 | ] ); 152 | 153 | $key = $api->get_public_api_key(); 154 | 155 | $this->assertEquals( 156 | $json['data']['publicKey'], 157 | $key, 158 | 'The public API should be determined by the WP101 API.' 159 | ); 160 | $this->assertEquals( $key, get_transient( $name ) ); 161 | } 162 | 163 | public function test_get_public_api_key_returns_from_transients_if_populated() { 164 | $api = API::get_instance(); 165 | $name = $api->get_public_api_key_name(); 166 | $key = uniqid(); 167 | 168 | set_transient( $name, $key, DAY_IN_SECONDS ); 169 | 170 | $this->assertEquals( $key, API::get_instance()->get_public_api_key() ); 171 | } 172 | 173 | public function test_get_public_api_key_surfaces_wp_errors() { 174 | $error = new WP_Error( 'msg' ); 175 | 176 | $this->set_expected_response( function () use ( $error ) { 177 | return $error; 178 | } ); 179 | 180 | $this->assertSame( $error, API::get_instance()->get_public_api_key() ); 181 | } 182 | 183 | public function test_get_public_api_key_returns_wp_error_if_no_key_was_found() { 184 | $this->set_expected_response( [ 185 | 'body' => wp_json_encode( [ 186 | 'status' => 'error', 187 | 'data' => 'some message', 188 | ] ), 189 | ] ); 190 | 191 | $this->assertTrue( 192 | is_wp_error( API::get_instance()->get_public_api_key() ), 193 | 'If a public key can\'t be determined, return a WP_Error object.' 194 | ); 195 | } 196 | 197 | /** 198 | * @ticket https://github.com/101videos/wp101plugin/issues/49 199 | */ 200 | public function test_get_public_api_key_name() { 201 | $hash = substr( md5( site_url( '/' ) ), 0, 8 ); 202 | 203 | $this->assertContains( 204 | $hash, 205 | API::get_instance()->get_public_api_key_name(), 206 | 'Expected the public API key transient name to include a hash of the site URL.' 207 | ); 208 | } 209 | 210 | public function test_get_addons() { 211 | $json = [ 212 | 'status' => 'success', 213 | 'data' => [ 214 | 'addons' => [], 215 | ], 216 | ]; 217 | 218 | $this->set_expected_response([ 219 | 'body' => wp_json_encode( $json ), 220 | ]); 221 | 222 | $this->assertEquals( $json['data'], API::get_instance()->get_addons() ); 223 | } 224 | 225 | public function test_get_addons_handles_wp_error() { 226 | $this->set_expected_response( function () { 227 | return new WP_Error( 'code', 'some message' ); 228 | } ); 229 | 230 | $this->assertEquals( 231 | [ 232 | 'addons' => [], 233 | ], 234 | API::get_instance()->get_addons() 235 | ); 236 | } 237 | 238 | public function test_get_addons_handles_malformed_responses() { 239 | $json = [ 240 | 'status' => 'success', 241 | 'data' => [ 242 | 'some data that has nothing to do with add-ons.', 243 | ], 244 | ]; 245 | 246 | $this->set_expected_response([ 247 | 'body' => wp_json_encode( $json ), 248 | ]); 249 | 250 | $this->assertEquals( [ 251 | 'addons' => [], 252 | ], API::get_instance()->get_addons() ); 253 | } 254 | 255 | public function test_get_addons_updates_add_on_urls() { 256 | $api = API::get_instance(); 257 | $name = $api->get_public_api_key_name(); 258 | $key = uniqid(); 259 | 260 | set_transient( $name, $key ); 261 | $this->set_expected_response([ 262 | 'body' => wp_json_encode( [ 263 | 'status' => 'success', 264 | 'data' => [ 265 | 'addons' => [ 266 | [ 267 | 'url' => 'http://example.com', 268 | ], 269 | ], 270 | ], 271 | ] ), 272 | ]); 273 | 274 | $this->assertEquals( 275 | 'http://example.com?apiKey=' . $key, 276 | $api->get_addons()['addons'][0]['url'], 277 | 'The public API key should be appended to all add-on URLs.' 278 | ); 279 | } 280 | 281 | public function test_get_addons_updates_adds_meets_requirements() { 282 | update_option( 'active_plugins', [ 283 | 'some-plugin/some-plugin.php', 284 | ] ); 285 | 286 | $this->set_expected_response([ 287 | 'body' => wp_json_encode( [ 288 | 'status' => 'success', 289 | 'data' => [ 290 | 'addons' => [ 291 | [ 292 | 'url' => 'http://example.com', 293 | 'restrictions' => [ 294 | 'plugins' => [ 295 | 'some-plugin/some-plugin.php', 296 | ], 297 | ], 298 | ], 299 | [ 300 | 'url' => 'http://example.com', 301 | 'restrictions' => [ 302 | 'plugins' => [ 303 | 'some-other-plugin/some-other-plugin.php', 304 | ], 305 | ], 306 | ], 307 | [ 308 | 'url' => 'http://example.com', 309 | 'restrictions' => [ 310 | 'plugins' => [ 311 | 'some-plugin/some-plugin.php', 312 | 'some-other-plugin/some-other-plugin.php', 313 | ], 314 | ], 315 | ], 316 | ], 317 | ], 318 | ] ), 319 | ]); 320 | 321 | $this->assertTrue( API::get_instance()->get_addons()['addons'][0]['meets_requirements'] ); 322 | $this->assertFalse( API::get_instance()->get_addons()['addons'][1]['meets_requirements'] ); 323 | 324 | // If multiple requirements are listed, only one must be met. 325 | $this->assertTrue( API::get_instance()->get_addons()['addons'][2]['meets_requirements'] ); 326 | } 327 | 328 | public function test_get_playlist() { 329 | $json = [ 330 | 'status' => 'success', 331 | 'data' => [ 332 | 'series' => [ 333 | 'slug' => 'some-slug', 334 | 'topics' => [ 335 | 'slug' => 'video-' . uniqid(), 336 | ], 337 | ], 338 | ], 339 | ]; 340 | 341 | $this->set_expected_response( [ 342 | 'body' => wp_json_encode( $json ), 343 | ] ); 344 | 345 | $this->assertEquals( $json['data'], API::get_instance()->get_playlist() ); 346 | } 347 | 348 | public function test_get_playlist_handles_wp_error() { 349 | $this->set_expected_response( function () { 350 | return new WP_Error( 'code', 'some message' ); 351 | } ); 352 | 353 | $this->assertEquals( [ 'series' => [] ], API::get_instance()->get_playlist() ); 354 | } 355 | 356 | public function test_get_playlist_handles_malformed_responses() { 357 | $this->set_expected_response( [ 358 | 'body' => wp_json_encode( [ 359 | 'status' => 'success', 360 | 'data' => [ 361 | 'some irrelevant values.', 362 | ], 363 | ] ), 364 | ] ); 365 | 366 | $this->assertEquals( [ 'series' => [] ], API::get_instance()->get_playlist() ); 367 | } 368 | 369 | /** 370 | * @ticket https://github.com/101videos/wp101plugin/issues/50 371 | */ 372 | public function test_get_playlist_can_filter_results_by_slug() { 373 | $playlist = [ 374 | 'series' => [ 375 | [ 376 | 'slug' => 'first-series', 377 | 'topics' => [ 378 | [ 379 | 'slug' => 'first-video', 380 | 'legacy_id' => 'plugin.1', 381 | ], 382 | [ 383 | 'slug' => 'second-video', 384 | 'legacy_id' => 'plugin.2', 385 | ] 386 | ], 387 | ], 388 | ], 389 | ]; 390 | 391 | $this->set_expected_response( [ 392 | 'body' => wp_json_encode( [ 393 | 'status' => 'success', 394 | 'data' => $playlist, 395 | ] ), 396 | ] ); 397 | 398 | 399 | add_filter( 'wp101_excluded_topics', function () { 400 | return [ 401 | 'first-video', 402 | ]; 403 | } ); 404 | 405 | $result = API::get_instance()->get_playlist(); 406 | 407 | $this->assertCount( 1, $result['series'][0]['topics'] ); 408 | 409 | $this->assertSame( 410 | $playlist['series'][0]['topics'][1]['slug'], 411 | current($result['series'][0]['topics'])['slug'] 412 | ); 413 | } 414 | 415 | /** 416 | * @ticket https://github.com/101videos/wp101plugin/issues/50 417 | */ 418 | public function test_get_playlist_can_filter_results_across_multiple_series() { 419 | $playlist = [ 420 | 'series' => [ 421 | [ 422 | 'slug' => 'first-series', 423 | 'topics' => [ 424 | [ 425 | 'slug' => 'first-video', 426 | 'legacy_id' => 'plugin.1', 427 | ], 428 | [ 429 | 'slug' => 'second-video', 430 | 'legacy_id' => 'plugin.2', 431 | ] 432 | ], 433 | ], 434 | [ 435 | 'slug' => 'second-series', 436 | 'topics' => [ 437 | [ 438 | 'slug' => 'third-video', 439 | 'legacy_id' => 'plugin.3', 440 | ], 441 | [ 442 | 'slug' => 'fourth-video', 443 | 'legacy_id' => 'plugin.4', 444 | ] 445 | ], 446 | ], 447 | ], 448 | ]; 449 | 450 | $this->set_expected_response( [ 451 | 'body' => wp_json_encode( [ 452 | 'status' => 'success', 453 | 'data' => $playlist, 454 | ] ), 455 | ] ); 456 | 457 | 458 | add_filter( 'wp101_excluded_topics', function () { 459 | return [ 460 | 'first-video', 461 | 'fourth-video', 462 | ]; 463 | } ); 464 | 465 | $result = API::get_instance()->get_playlist(); 466 | 467 | $this->assertCount( 1, $result['series'][0]['topics'] ); 468 | $this->assertSame( 469 | $playlist['series'][0]['topics'][1]['slug'], 470 | current($result['series'][0]['topics'])['slug'] 471 | ); 472 | $this->assertCount( 1, $result['series'][1]['topics'] ); 473 | $this->assertSame( 474 | $playlist['series'][1]['topics'][0]['slug'], 475 | current($result['series'][1]['topics'])['slug'] 476 | ); 477 | } 478 | 479 | /** 480 | * @ticket https://github.com/101videos/wp101plugin/issues/50 481 | */ 482 | public function test_get_playlist_can_filter_results_by_legacy_id() { 483 | $playlist = [ 484 | 'series' => [ 485 | [ 486 | 'slug' => 'first-series', 487 | 'topics' => [ 488 | [ 489 | 'slug' => 'first-video', 490 | 'legacy_id' => 'plugin.1', 491 | ], 492 | [ 493 | 'slug' => 'second-video', 494 | 'legacy_id' => 'plugin.2', 495 | ] 496 | ], 497 | ] 498 | ], 499 | ]; 500 | 501 | $this->set_expected_response( [ 502 | 'body' => wp_json_encode( [ 503 | 'status' => 'success', 504 | 'data' => $playlist, 505 | ] ), 506 | ] ); 507 | 508 | add_filter( 'wp101_excluded_topics', function () { 509 | return [ 510 | 'plugin.2', 511 | ]; 512 | } ); 513 | 514 | $result = API::get_instance()->get_playlist(); 515 | 516 | $this->assertCount( 1, $result['series'][0]['topics'] ); 517 | $this->assertSame( 518 | $playlist['series'][0]['topics'][0]['slug'], 519 | current($result['series'][0]['topics'])['slug'] 520 | ); 521 | } 522 | 523 | public function test_get_series() { 524 | $json = [ 525 | 'status' => 'success', 526 | 'data' => [ 527 | 'series' => [ 528 | [ 529 | 'slug' => 'first-series', 530 | ], 531 | [ 532 | 'slug' => 'second-series', 533 | ], 534 | ] 535 | ], 536 | ]; 537 | 538 | $this->set_expected_response( [ 539 | 'body' => wp_json_encode( $json ), 540 | ] ); 541 | 542 | $this->assertEquals( 543 | $json['data']['series'][1], 544 | API::get_instance()->get_series( 'second-series' ) 545 | ); 546 | } 547 | 548 | public function test_get_series_returns_false_if_no_matching_series_found() { 549 | $this->set_expected_response( [ 550 | 'body' => wp_json_encode( [ 551 | 'status' => 'success', 552 | 'data' => [ 553 | 'series' => [], 554 | ], 555 | ] ), 556 | ] ); 557 | 558 | $this->assertFalse( API::get_instance()->get_series( 'first-series' ) ); 559 | } 560 | 561 | public function test_get_topic() { 562 | $json = [ 563 | 'status' => 'success', 564 | 'data' => [ 565 | 'series' => [ 566 | [ 567 | 'topics' => [ 568 | [ 569 | 'slug' => 'first-topic', 570 | ], 571 | [ 572 | 'slug' => 'second-topic', 573 | ], 574 | ], 575 | ], 576 | ] 577 | ], 578 | ]; 579 | 580 | $this->set_expected_response( [ 581 | 'body' => wp_json_encode( $json ), 582 | ] ); 583 | 584 | $this->assertEquals( 585 | $json['data']['series'][0]['topics'][1], 586 | API::get_instance()->get_topic( 'second-topic' ) 587 | ); 588 | } 589 | 590 | public function test_get_topic_can_traverse_series() { 591 | $json = [ 592 | 'status' => 'success', 593 | 'data' => [ 594 | 'series' => [ 595 | [ 596 | 'topics' => [ 597 | [ 598 | 'slug' => 'first-topic', 599 | ], 600 | ], 601 | ], 602 | [ 603 | 'topics' => [ 604 | [ 605 | 'slug' => 'second-topic', 606 | ], 607 | ], 608 | ], 609 | ] 610 | ], 611 | ]; 612 | 613 | $this->set_expected_response( [ 614 | 'body' => wp_json_encode( $json ), 615 | ] ); 616 | 617 | $this->assertEquals( 618 | $json['data']['series'][1]['topics'][0], 619 | API::get_instance()->get_topic( 'second-topic' ) 620 | ); 621 | } 622 | 623 | public function test_get_topic_returns_false_if_no_matching_topic_found() { 624 | $this->set_expected_response( [ 625 | 'body' => wp_json_encode( [ 626 | 'status' => 'success', 627 | 'data' => [ 628 | 'series' => [ 629 | [ 630 | 'topics' => [ 631 | [ 632 | 'slug' => 'first-topic', 633 | ], 634 | ], 635 | ], 636 | ] 637 | ], 638 | ] ), 639 | ] ); 640 | 641 | $this->assertFalse( API::get_instance()->get_topic( 'second-topic' ) ); 642 | } 643 | 644 | public function test_account_can() { 645 | $api = API::get_instance(); 646 | 647 | $this->set_expected_response( [ 648 | 'body' => wp_json_encode( [ 649 | 'status' => 'success', 650 | 'data' => [ 651 | 'capabilities' => [ 'some-capability' ], 652 | ], 653 | ] ), 654 | ] ); 655 | 656 | $this->assertTrue( $api->account_can( 'some-capability' ) ); 657 | $this->assertFalse( $api->account_can( 'some-other-capability' ) ); 658 | } 659 | 660 | public function test_account_can_resolves_errors_to_false() { 661 | $this->set_expected_response( function () { 662 | return new WP_Error( 'msg' ); 663 | } ); 664 | 665 | $this->assertFalse( API::get_instance()->account_can( 'some-capability' ) ); 666 | } 667 | 668 | public function test_exchange_api_key() { 669 | $api = API::get_instance(); 670 | $api->set_api_key( uniqid() ); 671 | $new = md5( uniqid() ); // Easy way to get a random, 32 character string. 672 | 673 | $this->set_expected_response( [ 674 | 'body' => wp_json_encode( [ 675 | 'status' => 'success', 676 | 'data' => [ 677 | 'apiKey' => $new, 678 | ], 679 | ] ), 680 | ] ); 681 | 682 | $this->assertEquals( $new, $api->exchange_api_key()['apiKey'] ); 683 | } 684 | 685 | public function test_exchange_api_key_returns_early_for_empty_key() { 686 | $api = API::get_instance(); 687 | $api->set_api_key( '' ); 688 | 689 | $this->assertTrue( is_wp_error( $api->exchange_api_key() ) ); 690 | } 691 | 692 | public function test_exchange_api_key_passes_hidden_topic_ids() { 693 | $hidden = [ 4, 8, 15, 16, 23, 42 ]; 694 | update_option( 'wp101_hidden_topics', $hidden ); 695 | 696 | $api = API::get_instance(); 697 | $api->set_api_key( uniqid() ); 698 | 699 | $this->set_expected_response( function ( $preempt, $args ) use ( $hidden ) { 700 | $this->assertEquals( $hidden, $args['body']['hiddenTopics'] ); 701 | 702 | return $this->mock_http_response( [ 703 | 'body' => wp_json_encode( [ 704 | 'status' => 'success', 705 | 'data' => 'Hidden topics were passed!', 706 | ] ), 707 | ] ); 708 | } ); 709 | 710 | $this->assertEquals( 'Hidden topics were passed!', $api->exchange_api_key() ); 711 | } 712 | 713 | public function test_exchange_api_key_respects_wp101_get_hidden_topics_filter() { 714 | update_option( 'wp101_hidden_topics', [ 4, 8, 15, 16, 23, 42 ] ); 715 | 716 | add_filter( 'wp101_get_hidden_topics', function () { 717 | return [ 4, 8, 15 ]; 718 | } ); 719 | 720 | $api = API::get_instance(); 721 | $api->set_api_key( uniqid() ); 722 | 723 | $this->set_expected_response( function ( $preempt, $args ) { 724 | $this->assertEquals( [ 4, 8, 15 ], $args['body']['hiddenTopics'] ); 725 | 726 | return $this->mock_http_response( [ 727 | 'body' => wp_json_encode( [ 728 | 'status' => 'success', 729 | 'data' => 'Hidden topics were passed!', 730 | ] ), 731 | ] ); 732 | } ); 733 | 734 | $this->assertEquals( 'Hidden topics were passed!', $api->exchange_api_key() ); 735 | } 736 | 737 | public function test_exchange_api_key_passes_custom_topics() { 738 | $custom_topics = [ 739 | 'custom-topic' => [ 740 | 'title' => 'This is a custom topic', 741 | 'content' => '', 742 | ], 743 | ]; 744 | update_option( 'wp101_custom_topics', $custom_topics ); 745 | 746 | $api = API::get_instance(); 747 | $api->set_api_key( uniqid() ); 748 | 749 | $this->set_expected_response( function ( $preempt, $args ) use ( $custom_topics ) { 750 | $this->assertEquals( $custom_topics, $args['body']['customTopics'] ); 751 | 752 | return $this->mock_http_response( [ 753 | 'body' => wp_json_encode( [ 754 | 'status' => 'success', 755 | 'data' => 'Custom topics were passed!', 756 | ] ), 757 | ] ); 758 | } ); 759 | 760 | $this->assertEquals( 'Custom topics were passed!', $api->exchange_api_key() ); 761 | } 762 | 763 | public function test_exchange_api_key_respects_wp101_get_custom_help_topics_filter() { 764 | update_option( 'wp101_custom_topics', [ 'custom-topic' => [] ] ); 765 | 766 | add_filter( 'wp101_get_custom_help_topics', function () { 767 | return [ 'different-topic' => [] ]; 768 | } ); 769 | 770 | $api = API::get_instance(); 771 | $api->set_api_key( uniqid() ); 772 | 773 | $this->set_expected_response( function ( $preempt, $args ) { 774 | $this->assertEquals( [ 'different-topic'], array_keys( $args['body']['customTopics'] ) ); 775 | 776 | return $this->mock_http_response( [ 777 | 'body' => wp_json_encode( [ 778 | 'status' => 'success', 779 | 'data' => 'Custom topics were passed!', 780 | ] ), 781 | ] ); 782 | } ); 783 | 784 | $this->assertEquals( 'Custom topics were passed!', $api->exchange_api_key() ); 785 | } 786 | 787 | public function test_exchange_api_key_surfaces_wp_errors() { 788 | $error = new WP_Error( 'msg' ); 789 | 790 | $api = API::get_instance(); 791 | $api->set_api_key( uniqid() ); 792 | 793 | $this->set_expected_response( function () use ( $error ) { 794 | return $error; 795 | } ); 796 | 797 | $this->assertSame( $error, $api->exchange_api_key() ); 798 | } 799 | 800 | public function test_exchange_api_key_returns_wp_error_on_api_connection_error() { 801 | $this->set_expected_response( [ 802 | 'response' => [ 803 | 'code' => 403, 804 | 'message' => 'Forbidden', 805 | ], 806 | ] ); 807 | 808 | $this->assertTrue( is_wp_error( API::get_instance()->exchange_api_key() ) ); 809 | } 810 | 811 | public function test_exchange_api_key_returns_wp_error_on_api_error() { 812 | $this->set_expected_response( [ 813 | 'body' => wp_json_encode( [ 814 | 'status' => 'fail', 815 | 'data' => 'some message', 816 | ] ), 817 | ] ); 818 | 819 | $this->assertTrue( is_wp_error( API::get_instance()->exchange_api_key() ) ); 820 | } 821 | 822 | /** 823 | * @dataProvider build_uri_provider() 824 | */ 825 | public function test_build_uri( $path, $args, $expected ) { 826 | $api = API::get_instance(); 827 | $method = $this->get_accessible_method( $api, 'build_uri' ); 828 | 829 | $this->assertEquals( 830 | API::API_URL . $expected, 831 | $method->invoke( $api, $path, $args ) 832 | ); 833 | } 834 | 835 | public function build_uri_provider() { 836 | return [ 837 | 'Simple path' => [ 838 | '/test-path', 839 | [], 840 | '/test-path', 841 | ], 842 | 'Missing leading slash' => [ 843 | 'test-path', 844 | [], 845 | '/test-path', 846 | ], 847 | 'Query string arguments' => [ 848 | '/test-path', 849 | [ 'foo' => 'bar' ], 850 | '/test-path?foo=bar', 851 | ], 852 | 'Multiple query string arguments' => [ 853 | '/test-path', 854 | [ 'foo' => 'FooValue', 'bar' => 'BarValue' ], 855 | '/test-path?foo=FooValue&bar=BarValue', 856 | ], 857 | ]; 858 | } 859 | 860 | public function test_build_uri_enables_base_to_be_changed_via_constant() { 861 | define( 'WP101_API_URL', 'http://example.com' ); 862 | 863 | $api = API::get_instance(); 864 | $method = $this->get_accessible_method( $api, 'build_uri' ); 865 | 866 | $this->assertEquals( 867 | 'http://example.com/path', 868 | $method->invoke( $api, '/path' ), 869 | 'When the WP101_API_URL is set, it should take precedence over the default URL.' 870 | ); 871 | } 872 | 873 | public function test_send_request() { 874 | $key = uniqid(); 875 | $api = API::get_instance(); 876 | $method = $this->get_accessible_method( $api, 'send_request' ); 877 | 878 | $this->set_api_key( $key ); 879 | 880 | $response = $this->mock_http_response( [ 881 | 'body' => wp_json_encode( [ 882 | 'status' => 'success', 883 | 'data' => [], 884 | ] ) 885 | ] ); 886 | 887 | $this->set_expected_response( function ( $preempt, $args, $url ) use ( $key, $response ) { 888 | $this->assertEquals( 'GET', $args['method'] ); 889 | $this->assertContains( '/test-endpoint', $url ); 890 | $this->assertEquals( 'Bearer ' . $key, $args['headers']['Authorization'] ); 891 | $this->assertEquals( site_url(), $args['headers']['X-User-Domain'] ); 892 | $this->assertEquals( API::USER_AGENT, $args['user-agent']); 893 | 894 | return $response; 895 | } ); 896 | 897 | $this->assertEquals( 898 | [], // Value of $response['body']['data'] after being decoded. 899 | $method->invoke( $api, 'GET', '/test-endpoint' ), 900 | 'Did not receive expected response from send_request().' 901 | ); 902 | } 903 | 904 | /** 905 | * @link https://github.com/liquidweb/wp101plugin/issues/67 906 | */ 907 | public function test_send_request_aborts_if_no_api_key_is_set() { 908 | $api = API::get_instance(); 909 | $method = $this->get_accessible_method( $api, 'send_request' ); 910 | 911 | $this->set_api_key( '' ); 912 | 913 | $response = $method->invoke( $api, 'GET', '/test-endpoint' ); 914 | 915 | $this->assertTrue( is_wp_error( $response ) ); 916 | $this->assertSame( 'wp101-no-api-key', $response->get_error_code() ); 917 | } 918 | 919 | public function test_send_request_checks_response_status() { 920 | $api = API::get_instance(); 921 | $method = $this->get_accessible_method( $api, 'send_request' ); 922 | 923 | $response = $this->set_expected_response( [ 924 | 'body' => wp_json_encode( [ 925 | 'status' => 'fail', 926 | 'data' => [ 927 | 'apiKey' => 'Invalid API key.', 928 | ], 929 | ] ), 930 | ] ); 931 | 932 | $this->assertTrue( is_wp_error( $method->invoke( $api, 'GET', '/test-endpoint' ) ) ); 933 | } 934 | 935 | public function test_handle_error() { 936 | $api = Api::get_instance(); 937 | $method = $this->get_accessible_method( $api, 'handle_error' ); 938 | $prop = new \ReflectionProperty( $api, 'errors' ); 939 | $prop->setAccessible( true ); 940 | $error1 = new WP_Error( 'test1', 'Foo' ); 941 | $error2 = new WP_Error( 'test2', 'Bar' ); 942 | 943 | $this->assertEmpty( $prop->getValue( $api ) ); 944 | 945 | $method->invoke( $api, $error1 ); 946 | $method->invoke( $api, $error2 ); 947 | 948 | $this->assertContains( $error1, $prop->getValue( $api ) ); 949 | $this->assertContains( $error2, $prop->getValue( $api ) ); 950 | } 951 | 952 | public function test_handle_error_overwrites_for_duplicates() { 953 | $api = Api::get_instance(); 954 | $method = $this->get_accessible_method( $api, 'handle_error' ); 955 | $prop = new \ReflectionProperty( $api, 'errors' ); 956 | $prop->setAccessible( true ); 957 | $error1 = new WP_Error( 'test', 'Foo' ); 958 | $error2 = new WP_Error( 'test', 'Bar' ); 959 | 960 | $this->assertEmpty( $prop->getValue( $api ) ); 961 | 962 | $method->invoke( $api, $error1 ); 963 | $method->invoke( $api, $error2 ); 964 | 965 | $this->assertNotContains( $error1, $prop->getValue( $api ) ); 966 | $this->assertContains( $error2, $prop->getValue( $api ) ); 967 | } 968 | 969 | /** 970 | * Mock the expected HTTP response. 971 | * 972 | * @param array|callable $response The response that should be returned from the HTTP request. 973 | */ 974 | protected function set_expected_response( $response ) { 975 | if ( is_callable( $response ) ) { 976 | $callback = $response; 977 | } else { 978 | $callback = function ( $preempt, $args, $url ) use ( $response ) { 979 | return $this->mock_http_response( $response ); 980 | }; 981 | } 982 | 983 | add_filter( 'pre_http_request', $callback, 1, 3 ); 984 | } 985 | 986 | /** 987 | * The pre_http_request filter requires an array be returned that contains the same keys as a 988 | * standard WordPress HTTP response. This fills in the gaps, enabling our tests to override only 989 | * the properties needed. 990 | * 991 | * @link https://developer.wordpress.org/reference/hooks/pre_http_request 992 | * 993 | * @param array $props Optional. Properties that should be set on the response, from the list of 994 | * keys containing 'headers', 'body', 'response', 'cookies', and 'filename'. 995 | * Default is empty. 996 | * @return array A mocked HTTP response. 997 | */ 998 | protected function mock_http_response( $props = [] ) { 999 | return array_merge( [ 1000 | 'headers' => [], 1001 | 'body' => '', 1002 | 'response' => [ 1003 | 'code' => 200, 1004 | 'message' => 'OK', 1005 | ], 1006 | 'cookies' => [], 1007 | 'filename' => '', 1008 | ], (array) $props ); 1009 | } 1010 | } 1011 | -------------------------------------------------------------------------------- /tests/test-deprecated.php: -------------------------------------------------------------------------------- 1 | setExpectedIncorrectUsage( 'WP101_Plugin' ); 22 | 23 | new WP101_Plugin; 24 | } 25 | 26 | public function test_wp101_plugin_static_methods_are_deprecated() { 27 | $this->setExpectedIncorrectUsage( 'WP101_Plugin::get_instance' ); 28 | 29 | WP101_Plugin::get_instance(); 30 | } 31 | 32 | /** 33 | * @testWith ["wp101_after_edit_help_topics"] 34 | * ["wp101_after_edit_custom_help_topics"] 35 | * ["wp101_after_help_topics"] 36 | * ["wp101_after_custom_help_topics"] 37 | * ["wp101_admin_action_add-video"] 38 | * ["wp101_admin_action_update-video"] 39 | * ["wp101_admin_action_restrict-admin"] 40 | * ["wp101_pre_includes"] 41 | */ 42 | public function test_discover_deprecated_actions( $action ) { 43 | add_action( $action, '__return_false' ); 44 | 45 | $this->setExpectedIncorrectUsage( 'Action ' . $action ); 46 | 47 | do_action( 'init' ); 48 | } 49 | 50 | /** 51 | * @testWith ["wp101_default_settings_role"] 52 | * ["wp101_too_many_admins"] 53 | * ["wp101_settings_management_user_args"] 54 | * ["wp101_get_document"] 55 | * ["wp101_get_help_topics"] 56 | * ["wp101_get_custom_help_topics"] 57 | * ["wp101_get_hidden_topics"] 58 | */ 59 | public function test_discover_deprecated_filters( $filter ) { 60 | add_filter( $filter, '__return_false' ); 61 | 62 | $this->setExpectedIncorrectUsage( 'Filter ' . $filter ); 63 | 64 | do_action( 'init' ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /tests/test-migrate.php: -------------------------------------------------------------------------------- 1 | mock_api(); 42 | $api->shouldReceive( 'exchange_api_key' ) 43 | ->once() 44 | ->andReturn( [ 45 | 'apiKey' => self::CURRENT_API_KEY, 46 | ] ); 47 | $this->set_api_key( self::LEGACY_API_KEY ); 48 | 49 | // Populate some other legacy options. 50 | update_option( 'wp101_custom_topics', [ 51 | 'custom-topic' => [ 52 | 'title' => 'Custom Topic', 53 | 'content' => 'Some video embed', 54 | ], 55 | ] ); 56 | update_option( 'wp101_hidden_topics', [ 1, 2, 3 ] ); 57 | 58 | Migrate\maybe_migrate(); 59 | 60 | $this->assertEquals( self::CURRENT_API_KEY, get_option( 'wp101_api_key' ) ); 61 | $this->assertEquals( 10, has_action( 'admin_notices', 'WP101\Migrate\render_migration_success_notice' ) ); 62 | $this->assertFalse( 63 | get_option( 'wp101_custom_topics', false ), 64 | 'Expected the wp101_custom_topics option to be deleted.' 65 | ); 66 | $this->assertFalse( 67 | get_option( 'wp101_hidden_topics', false ), 68 | 'Expected the wp101_hidden_topics option to be deleted.' 69 | ); 70 | } 71 | 72 | /** 73 | * If the wp101_api_key option has legacy_options, attempt to exchange it. 74 | * 75 | * @ticket https://github.com/101videos/wp101plugin/issues/55 76 | */ 77 | public function test_maybe_migrate_for_legacy_options() { 78 | $api = $this->mock_api(); 79 | $api->shouldReceive( 'exchange_api_key' ) 80 | ->once() 81 | ->andReturn( [ 82 | 'apiKey' => self::CURRENT_API_KEY, 83 | ] ); 84 | $this->set_api_key( self::CURRENT_API_KEY ); 85 | 86 | update_option( 'wp101_custom_topics', [ 87 | 'custom-topic' => [ 88 | 'title' => 'Custom Topic', 89 | 'content' => 'Some video embed', 90 | ], 91 | ] ); 92 | update_option( 'wp101_hidden_topics', [ 1, 2, 3 ] ); 93 | 94 | $this->assertFalse( 95 | Migrate\api_key_needs_migration( $api->get_api_key() ), 96 | 'This test is predicated on the API key not needing migration, but the options do.' 97 | ); 98 | 99 | Migrate\maybe_migrate(); 100 | 101 | $this->assertEquals( 10, has_action( 'admin_notices', 'WP101\Migrate\render_migration_success_notice' ) ); 102 | } 103 | 104 | /** 105 | * The maybe_migrate() function should return early if there's nothing to migrate. 106 | */ 107 | public function test_maybe_migrate_returns_early_if_no_key_is_present() { 108 | delete_option( 'wp101_api_key' ); 109 | 110 | $api = $this->mock_api(); 111 | $api->shouldReceive( 'exchange_api_key' )->never(); 112 | 113 | Migrate\maybe_migrate(); 114 | 115 | $this->assertFalse( has_action( 'admin_notices' ) ); 116 | } 117 | 118 | /** 119 | * If an API key already matches the expected pattern *and* the legacy options have been 120 | * removed, don't attempt to exchange it. 121 | */ 122 | public function test_maybe_migrate_returns_early_if_keys_do_not_require_migration() { 123 | $api = $this->mock_api(); 124 | $api->shouldReceive( 'exchange_api_key' )->never(); 125 | $this->set_api_key( self::CURRENT_API_KEY ); 126 | 127 | Migrate\maybe_migrate(); 128 | 129 | $this->assertFalse( has_action( 'admin_notices' ) ); 130 | } 131 | 132 | /** 133 | * If we receive a WP_Error while exchanging the key, ensure we handle it properly. 134 | */ 135 | public function test_maybe_migrate_handles_wp_errors() { 136 | $api = $this->mock_api(); 137 | $api->shouldReceive( 'exchange_api_key' ) 138 | ->once() 139 | ->andReturn( new WP_Error( 'some error' ) ); 140 | $this->set_api_key( self::LEGACY_API_KEY ); 141 | 142 | $this->expectException( Warning::class ); 143 | 144 | Migrate\maybe_migrate(); 145 | 146 | $this->assertEquals( self::LEGACY_API_KEY, get_option( 'wp101_api_key' ) ); 147 | $this->assertTrue( has_action( 'admin_notices', 'WP101\Migrate\render_migration_failure_notice' ) ); 148 | } 149 | 150 | /** 151 | * @group multisite 152 | * @ticket https://github.com/leftlane/wp101plugin/issues/47 153 | */ 154 | public function test_maybe_migrate_will_schedule_a_bulk_migration_task() { 155 | $this->skip_if_not_multisite(); 156 | 157 | $api = $this->mock_api(); 158 | $api->shouldReceive( 'exchange_api_key' ) 159 | ->once() 160 | ->andReturn( [ 161 | 'apiKey' => self::CURRENT_API_KEY, 162 | ] ); 163 | $this->set_api_key( self::LEGACY_API_KEY ); 164 | 165 | $user_id = $this->factory->user->create(); 166 | 167 | grant_super_admin( $user_id ); 168 | wp_set_current_user( $user_id ); 169 | 170 | Migrate\maybe_migrate(); 171 | 172 | $this->assertEquals( self::CURRENT_API_KEY, get_option( 'wp101_api_key' ) ); 173 | 174 | $this->assertNotEmpty(wp_next_scheduled('wp101-bulk-migration')); 175 | $this->assertTrue( 176 | get_site_option( 'wp101-bulk-migration-lock', false ), 177 | 'A network-wide option should be set to prevent multiple runs.' 178 | ); 179 | } 180 | 181 | /** 182 | * @group multisite 183 | * @ticket https://github.com/leftlane/wp101plugin/issues/47 184 | */ 185 | public function test_maybe_migrate_will_only_schedule_a_bulk_migration_once() { 186 | add_site_option( 'wp101-bulk-migration-lock', true ); 187 | 188 | $user_id = $this->factory->user->create(); 189 | 190 | grant_super_admin( $user_id ); 191 | wp_set_current_user( $user_id ); 192 | 193 | $this->mock_api()->shouldReceive( 'exchange_api_key' )->never(); 194 | 195 | Migrate\maybe_migrate(); 196 | 197 | $this->assertFalse( wp_next_scheduled( 'wp101-bulk-migration' ) ); 198 | } 199 | 200 | /** 201 | * @group multisite 202 | * @ticket https://github.com/leftlane/wp101plugin/issues/47 203 | */ 204 | public function test_maybe_migrate_will_only_schedule_a_bulk_migration_for_network_admins() { 205 | $user_id = $this->factory->user->create(); 206 | 207 | wp_set_current_user( $user_id ); // User is not a super admin. 208 | 209 | $this->mock_api()->shouldReceive( 'exchange_api_key' )->never(); 210 | 211 | Migrate\maybe_migrate(); 212 | 213 | $this->assertFalse( wp_next_scheduled( 'wp101-bulk-migration' ) ); 214 | } 215 | 216 | public function test_api_key_needs_migration() { 217 | $this->assertTrue( Migrate\api_key_needs_migration( self::LEGACY_API_KEY ) ); 218 | $this->assertFalse( Migrate\api_key_needs_migration( self::CURRENT_API_KEY ) ); 219 | $this->assertFalse( Migrate\api_key_needs_migration( '') ); 220 | } 221 | 222 | public function test_render_migration_success_notice() { 223 | ob_start(); 224 | Migrate\render_migration_success_notice(); 225 | $output = ob_get_clean(); 226 | 227 | $this->assertContainsSelector( '#wp101-api-key-upgraded', $output ); 228 | } 229 | 230 | public function test_render_migration_failure_notice() { 231 | ob_start(); 232 | Migrate\render_migration_failure_notice(); 233 | $output = ob_get_clean(); 234 | 235 | $this->assertContainsSelector( '#wp101-api-key-upgrade-failed', $output ); 236 | } 237 | 238 | /** 239 | * Scenario: An existing subscriber has a (valid) legacy API key saved in wp-config.php. 240 | * 241 | * They should be given instructions on how to update the key, including the new value. 242 | * 243 | * @requires extension runkit 244 | * 245 | * @link https://github.com/liquidweb/wp101plugin/issues/34 246 | */ 247 | public function test_old_key_requires_migration() { 248 | define( 'WP101_API_KEY', self::LEGACY_API_KEY ); 249 | 250 | $api = $this->mock_api(); 251 | $api->shouldReceive( 'exchange_api_key' ) 252 | ->once() 253 | ->andReturn( [ 254 | 'apiKey' => self::CURRENT_API_KEY, 255 | ] ); 256 | 257 | Migrate\maybe_migrate(); 258 | 259 | $this->assertEquals( self::CURRENT_API_KEY, get_option( 'wp101_api_key' ) ); 260 | $this->assertEquals( 10, has_action( 'admin_notices', 'WP101\Migrate\render_constant_upgrade_notice' ) ); 261 | } 262 | 263 | /** 264 | * Scenario: An existing subscriber has a (invalid) legacy API key saved in wp-config.php. 265 | * 266 | * They should be given instructions on how to remove the key. 267 | * 268 | * @requires extension runkit 269 | * 270 | * @link https://github.com/liquidweb/wp101plugin/issues/34 271 | */ 272 | public function test_invalid_key_needs_removed() { 273 | define( 'WP101_API_KEY', 'xxx' ); 274 | 275 | $api = $this->mock_api(); 276 | $api->shouldReceive( 'exchange_api_key' ) 277 | ->once() 278 | ->andReturn( new WP_Error( 'wp101-api', 'Message from the WP_Error object' ) ); 279 | 280 | $this->expectException( Warning::class ); 281 | 282 | Migrate\maybe_migrate(); 283 | 284 | $this->assertContainsSelector( '#wp101-api-key-constant-remove-notice', $output ); 285 | $this->assertContains( "'WP101_API_KEY', '" . self::LEGACY_API_KEY . "'", $output ); 286 | } 287 | 288 | /** 289 | * @group multisite 290 | * @ticket https://github.com/leftlane/wp101plugin/issues/47 291 | */ 292 | public function test_migrate_multisite() { 293 | $this->skip_if_not_multisite(); 294 | 295 | $blog_ids = $this->factory->blog->create_many( 3 ); 296 | 297 | foreach ( $blog_ids as $blog_id ) { 298 | add_blog_option( $blog_id, 'wp101_api_key', self::LEGACY_API_KEY ); 299 | } 300 | 301 | $api = $this->mock_api(); 302 | $api->shouldReceive( 'exchange_api_key' ) 303 | ->times( 3 ) 304 | ->andReturn( [ 305 | 'apiKey' => self::CURRENT_API_KEY, 306 | ] ); 307 | 308 | $this->assertSame( 3, Migrate\migrate_multisite() ); 309 | 310 | foreach ( $blog_ids as $blog_id ) { 311 | $this->assertSame( 312 | self::CURRENT_API_KEY, 313 | get_blog_option( $blog_id, 'wp101_api_key' ), 314 | "The API key should have been updated for blog #{$blog_id}." 315 | ); 316 | } 317 | } 318 | 319 | /** 320 | * @group multisite 321 | * @ticket https://github.com/leftlane/wp101plugin/issues/47 322 | */ 323 | public function test_migrate_multisite_will_batch_queries() { 324 | $this->skip_if_not_multisite(); 325 | 326 | $blog_ids = $this->factory->blog->create_many( 7 ); 327 | 328 | foreach ( $blog_ids as $blog_id ) { 329 | add_blog_option( $blog_id, 'wp101_api_key', self::LEGACY_API_KEY ); 330 | } 331 | 332 | $api = $this->mock_api(); 333 | $api->shouldReceive( 'exchange_api_key' ) 334 | ->andReturn( [ 335 | 'apiKey' => self::CURRENT_API_KEY, 336 | ] ); 337 | 338 | $this->assertSame( 7, Migrate\migrate_multisite( 3 ) ); 339 | 340 | foreach ( $blog_ids as $blog_id ) { 341 | $this->assertSame( 342 | self::CURRENT_API_KEY, 343 | get_blog_option( $blog_id, 'wp101_api_key' ), 344 | "The API key should have been updated for blog #{$blog_id}." 345 | ); 346 | } 347 | } 348 | 349 | /** 350 | * @group multisite 351 | * @ticket https://github.com/leftlane/wp101plugin/issues/47 352 | */ 353 | public function test_migrate_multisite_will_remove_lock_if_an_error_occurs() { 354 | $this->skip_if_not_multisite(); 355 | 356 | $blog_ids = $this->factory->blog->create_many( 2 ); 357 | 358 | add_blog_option( $blog_ids[0], 'wp101_api_key', self::LEGACY_API_KEY ); 359 | add_blog_option( $blog_ids[1], 'wp101_api_key', self::LEGACY_API_KEY ); 360 | 361 | $api = $this->mock_api(); 362 | $api->shouldReceive( 'exchange_api_key' ) 363 | ->andReturn( new WP_Error( 'wp101-migration', 'Some error message' ) ); 364 | 365 | $this->expectException( Warning::class ); 366 | 367 | $this->assertSame( 0, Migrate\migrate_multisite() ); 368 | $this->assertEmpty( get_site_option( 'wp101-bulk-migration-lock' ) ); 369 | } 370 | 371 | /** 372 | * @group multisite 373 | * @ticket https://github.com/leftlane/wp101plugin/issues/47 374 | */ 375 | public function test_migrate_multisite_aborts_early_if_not_multisite() { 376 | $this->skip_if_multisite(); 377 | 378 | $this->assertSame( 0, Migrate\migrate_multisite() ); 379 | } 380 | } 381 | -------------------------------------------------------------------------------- /tests/test-settings.php: -------------------------------------------------------------------------------- 1 | set_api_key( '' ); 20 | 21 | ob_start(); 22 | Admin\render_settings_page(); 23 | $output = ob_get_clean(); 24 | 25 | $this->assertContainsSelector( '#wp101-settings-api-key-form', $output ); 26 | $this->assertNotContainsSelector( '#wp101-settings-api-key-display', $output ); 27 | $this->assertHasElementWithAttributes( 28 | [ 29 | 'name' => 'wp101_api_key', 30 | 'id' => 'wp101-api-key', 31 | ], 32 | $output 33 | ); 34 | } 35 | 36 | public function test_hides_api_key_form_if_already_set() { 37 | $key = $this->set_api_key(); 38 | $masked = substr( $key, 0, 4 ); 39 | 40 | ob_start(); 41 | Admin\render_settings_page(); 42 | $output = ob_get_clean(); 43 | 44 | $this->assertHasElementWithAttributes( 45 | [ 46 | 'id' => 'wp101-settings-api-key-form', 47 | 'class' => 'hide-if-js', 48 | ], 49 | $output 50 | ); 51 | 52 | $this->assertHasElementWithAttributes( 53 | [ 54 | 'id' => 'wp101-settings-api-key-display', 55 | ], 56 | $output 57 | ); 58 | 59 | $this->assertRegExp( 60 | '/\' . preg_quote( substr( $key, 0, 4 ) ) . '(●)+\<\/code\>/', 61 | $output 62 | ); 63 | } 64 | 65 | /** 66 | * @requires extension runkit 67 | */ 68 | public function test_hides_api_key_form_if_set_via_constant() { 69 | define( 'WP101_API_KEY', md5( uniqid() ) ); 70 | 71 | ob_start(); 72 | Admin\render_settings_page(); 73 | $output = ob_get_clean(); 74 | 75 | $this->assertContainsSelector( '#wp101-api-key-set-via-constant-notice', $output ); 76 | $this->assertNotContainsSelector( '#wp101-api-key', $output ); 77 | $this->assertNotContainsSelector(' #wp101-settings-replace-api-key', $output ); 78 | } 79 | 80 | public function test_public_key_is_cleared_when_private_key_changes() { 81 | $api = $this->mock_api(); 82 | $api->shouldReceive( 'clear_api_key' )->once(); 83 | $api->shouldReceive( 'get_public_api_key' )->once(); 84 | $name = $api->get_public_api_key_name(); 85 | 86 | update_option( 'wp101_api_key', md5( uniqid() ) ); 87 | set_transient( $name, uniqid(), DAY_IN_SECONDS ); 88 | 89 | // Change the private key. 90 | update_option( 'wp101_api_key', md5( uniqid() ) ); 91 | 92 | $this->assertFalse( get_transient( $name ) ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/test-shortcode.php: -------------------------------------------------------------------------------- 1 | assertTrue( 16 | shortcode_exists( 'wp101' ), 17 | 'The [wp101] shortcode has not been registered.' 18 | ); 19 | } 20 | 21 | public function test_can_show_course() { 22 | wp_set_current_user( $this->factory()->user->create() ); 23 | Shortcode\register_scripts_styles(); 24 | 25 | $post = $this->factory()->post->create( [ 26 | 'post_status' => 'private', 27 | ] ); 28 | $api = $this->mock_api(); 29 | 30 | $api->shouldReceive( 'account_can' )->andReturn( true ); 31 | $api->shouldReceive( 'get_series' ) 32 | ->with( 'test-series' ) 33 | ->andReturn( [ 34 | 'title' => 'Title', 35 | 'topics' => [], 36 | ] ); 37 | 38 | $this->go_to( get_permalink( $post ) ); 39 | 40 | $this->assertNotEmpty( 41 | Shortcode\render_shortcode( [ 42 | 'course' => 'test-series', 43 | ] ) 44 | ); 45 | $this->assertTrue( wp_style_is( 'wp101', 'enqueued' ), 'Expected the styles to be enqueued.' ); 46 | } 47 | 48 | public function test_handles_missing_course() { 49 | wp_set_current_user( $this->factory()->user->create() ); 50 | 51 | $post = $this->factory()->post->create( [ 52 | 'post_status' => 'private', 53 | ] ); 54 | $api = $this->mock_api(); 55 | 56 | $api->shouldReceive( 'account_can' )->andReturn( true ); 57 | $api->shouldReceive( 'get_series' )->andReturn( false ); 58 | 59 | $this->go_to( get_permalink( $post ) ); 60 | 61 | $this->assertEmpty( 62 | Shortcode\render_shortcode( [ 63 | 'course' => 'test-series', 64 | ] ) 65 | ); 66 | } 67 | 68 | public function test_can_show_topic() { 69 | wp_set_current_user( $this->factory()->user->create() ); 70 | Shortcode\register_scripts_styles(); 71 | 72 | $post = $this->factory()->post->create( [ 73 | 'post_status' => 'private', 74 | ] ); 75 | $api = $this->mock_api(); 76 | 77 | $api->shouldReceive( 'account_can' )->andReturn( true ); 78 | $api->shouldReceive( 'get_topic' ) 79 | ->with( 'test-topic' ) 80 | ->andReturn( [ 81 | 'title' => 'Title', 82 | 'slug' => 'test-topic', 83 | 'url' => 'http://example.com/test-topic', 84 | 'description' => 'Foo bar baz', 85 | ] ); 86 | 87 | $this->go_to( get_permalink( $post ) ); 88 | 89 | $this->assertNotEmpty( 90 | Shortcode\render_shortcode( [ 91 | 'video' => 'test-topic', 92 | ] ) 93 | ); 94 | $this->assertTrue( wp_style_is( 'wp101', 'enqueued' ), 'Expected the styles to be enqueued.' ); 95 | } 96 | 97 | public function test_handles_missing_topic() { 98 | wp_set_current_user( $this->factory()->user->create() ); 99 | 100 | $post = $this->factory()->post->create( [ 101 | 'post_status' => 'private', 102 | ] ); 103 | $api = $this->mock_api(); 104 | 105 | $api->shouldReceive( 'account_can' )->andReturn( true ); 106 | $api->shouldReceive( 'get_topic' )->andReturn( false ); 107 | 108 | $this->go_to( get_permalink( $post ) ); 109 | 110 | $this->assertEmpty( 111 | Shortcode\render_shortcode( [ 112 | 'video' => 'test-topic', 113 | ] ) 114 | ); 115 | } 116 | 117 | public function test_gives_precedence_to_courses_over_videos() { 118 | wp_set_current_user( $this->factory()->user->create() ); 119 | $post = $this->factory()->post->create( [ 120 | 'post_status' => 'private', 121 | ] ); 122 | $api = $this->mock_api(); 123 | 124 | $api->shouldReceive( 'account_can' )->andReturn( true ); 125 | $api->shouldReceive( 'get_series' ) 126 | ->once() 127 | ->with( 'test-series' ) 128 | ->andReturn( [ 129 | 'title' => 'Title', 130 | 'topics' => [], 131 | ] ); 132 | 133 | $this->go_to( get_permalink( $post ) ); 134 | 135 | $this->assertNotEmpty( 136 | Shortcode\render_shortcode( [ 137 | 'course' => 'test-series', 138 | 'video' => 'test-topic', 139 | ] ) 140 | ); 141 | } 142 | 143 | public function test_shortcode_debug() { 144 | $this->assertEmpty( 145 | Shortcode\shortcode_debug( 'Foo bar' ), 146 | 'Unauthenticated users should see an empty string.' 147 | ); 148 | 149 | wp_set_current_user( $this->factory()->user->create( [ 150 | 'role' => 'editor', 151 | ] ) ); 152 | 153 | $this->assertEquals( 154 | '', 155 | Shortcode\shortcode_debug( 'Foo bar' ), 156 | 'Authenticated users should see the debug message.' 157 | ); 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /tests/test-template-tags.php: -------------------------------------------------------------------------------- 1 | set_api_key(); 19 | 20 | $this->assertEquals( $key, TemplateTags\get_api_key() ); 21 | } 22 | 23 | public function test_get_api_key_can_return_empty_string() { 24 | $this->assertEmpty( TemplateTags\get_api_key() ); 25 | } 26 | 27 | public function test_current_user_can_purchase_addons() { 28 | $this->assertFalse( TemplateTags\current_user_can_purchase_addons(), 'User is not authenticated.' ); 29 | 30 | wp_set_current_user( $this->factory()->user->create( [ 31 | 'role' => 'author', 32 | ] ) ); 33 | 34 | $this->assertTrue( TemplateTags\current_user_can_purchase_addons() ); 35 | 36 | add_filter( 'wp101_addon_capability', function () { 37 | return 'unfiltered_html'; 38 | } ); 39 | 40 | $this->assertFalse( current_user_can( 'unfiltered_html' ) ); 41 | $this->assertFalse( TemplateTags\current_user_can_purchase_addons(), 'Required capability has changed.' ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /tests/test-uninstall.php: -------------------------------------------------------------------------------- 1 | assertEquals( 10, has_action( 20 | 'deactivate_' . substr( dirname( __DIR__ ) . '/wp101.php', 1 ), 21 | 'WP101\\Uninstall\\clear_caches' 22 | ), 'Expected to see clear_caches called on plugin deactivation.' ); 23 | } 24 | 25 | public function test_uninstall_plugin() { 26 | $options = array( 27 | API::API_KEY_OPTION, 28 | 'wp101_db_version', 29 | 'wp101_hidden_topics', 30 | 'wp101_custom_topics', 31 | 'wp101_admin_restriction', 32 | ); 33 | 34 | foreach ( $options as $option ) { 35 | add_option( $option, uniqid() ); 36 | } 37 | 38 | Uninstall\uninstall_plugin(); 39 | 40 | foreach ( $options as $option ) { 41 | $this->assertEmpty( 42 | get_option( $option ), 43 | "Option '$option' was not removed along with the plugin." 44 | ); 45 | } 46 | } 47 | 48 | public function test_clear_caches() { 49 | $transients = array( 50 | API::get_instance()->get_public_api_key_name(), 51 | 'wp101_topics', 52 | 'wp101_jetpack_topics', 53 | 'wp101_woocommerce_topics', 54 | 'wp101_wpseo_topics', 55 | 'wp101_message', 56 | 'wp101_get_admin_count', 57 | 'wp101_api_key_valid', 58 | ); 59 | 60 | foreach ( $transients as $transient ) { 61 | set_transient( $transient, uniqid() ); 62 | } 63 | 64 | Uninstall\clear_caches(); 65 | 66 | foreach ( $transients as $transient ) { 67 | $this->assertEmpty( 68 | get_transient( $transient ), 69 | "Transient '$transient' was not cleared." 70 | ); 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /tests/testcase.php: -------------------------------------------------------------------------------- 1 | get_public_api_key_name() ); 28 | 29 | $instance = new ReflectionProperty( API::get_instance(), 'instance' ); 30 | $instance->setAccessible( true ); 31 | $instance->setValue( null ); 32 | 33 | Mockery::close(); 34 | } 35 | 36 | /** 37 | * Dequeue all scripts and styles. 38 | * 39 | * @after 40 | */ 41 | public function dequeue_assets() { 42 | global $wp_styles, $wp_scripts; 43 | 44 | unset( $wp_styles, $wp_scripts ); 45 | } 46 | 47 | /** 48 | * Tear down any custom menus. 49 | * 50 | * @after 51 | */ 52 | public function reset_menus() { 53 | global $menu, $submenu, $_parent_pages; 54 | 55 | $menu = null; 56 | $submenu = null; 57 | $_parent_pages = []; 58 | } 59 | 60 | /** 61 | * Clean up the WP101_API_KEY constant. 62 | * 63 | * @after 64 | */ 65 | public function remove_constants() { 66 | if ( function_exists( 'runkit_constant_remove' ) && defined( 'WP101_API_KEY' ) ) { 67 | runkit_constant_remove( 'WP101_API_KEY' ); 68 | } 69 | } 70 | 71 | /** 72 | * Skip the test if we're running in a WordPress Multisite environment. 73 | * 74 | * @return bool 75 | */ 76 | protected function skip_if_multisite() { 77 | if ( is_multisite() ) { 78 | $this->markTestSkipped( 'This test will not run under WordPress Multisite.' ); 79 | } 80 | } 81 | 82 | /** 83 | * Skip the test unless we're running in a WordPress Multisite environment. 84 | * 85 | * @return bool 86 | */ 87 | protected function skip_if_not_multisite() { 88 | if ( ! is_multisite() ) { 89 | $this->markTestSkipped( 'This test will only run under WordPress Multisite.' ); 90 | } 91 | } 92 | 93 | /** 94 | * Return a ReflectionMethod with given protected/private $method accessible. 95 | * 96 | * @param object|string $class A class name or instance that contains the given method. 97 | * @param string $method The method that should be made accessible. 98 | * @return ReflectionMethod 99 | */ 100 | protected function get_accessible_method( $class, $method ) { 101 | $reflection = new ReflectionMethod( $class, $method ); 102 | $reflection->setAccessible( true ); 103 | 104 | return $reflection; 105 | } 106 | 107 | /** 108 | * Retrieve a Mockery version of the API class. 109 | * 110 | * @return Mockery\Mock A Mockery version of the API class. 111 | */ 112 | protected function mock_api() { 113 | $api = API::get_instance(); 114 | $mock = Mockery::mock( $api )->shouldAllowMockingProtectedMethods()->makePartial(); 115 | $instance = new ReflectionProperty( $api, 'instance' ); 116 | $instance->setAccessible( true ); 117 | $instance->setValue( $mock ); 118 | 119 | return API::get_instance(); 120 | } 121 | 122 | /** 123 | * Set the environment's API key. 124 | * 125 | * @param string $api_key|bool Optional. The API key value to set. If equal to FALSE, a random 126 | * key will be generated. 127 | * @return string The API key stored. 128 | */ 129 | protected function set_api_key( $api_key = false ) { 130 | if ( false === $api_key ) { 131 | $api_key = md5( uniqid() ); 132 | } 133 | 134 | update_option( 'wp101_api_key', $api_key ); 135 | 136 | return $api_key; 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /views/add-ons.php: -------------------------------------------------------------------------------- 1 | 15 | 16 |
    17 |

    18 | 19 |

    20 | 21 | 22 | 23 | 24 | 25 |
    26 |

    27 |
    28 | 29 | 30 | 31 |

    32 | 33 |
    34 | 35 | 36 | 37 |
    38 |

    39 | 40 | 41 | 42 | 43 |

    44 | 45 |
    46 | 47 |
    48 | 49 | 50 | 51 |

    52 | 53 | 54 | 55 | 56 |

    57 | 58 | 62 | 63 |

    64 | 65 | 66 |
    67 |

    68 |
    69 | 70 | 71 |

    72 | 73 | 77 | 78 |

    79 | 80 |
    81 | 82 | 83 |
    84 | 85 | 86 |
    87 | -------------------------------------------------------------------------------- /views/listings.php: -------------------------------------------------------------------------------- 1 | site_url(), 13 | ); 14 | 15 | ?> 16 | 17 |
    18 |

    19 | 20 |

    21 | 22 | 23 | 24 |
    25 |

    26 |
    27 | 28 |
    29 |
    30 | 31 | 51 | 52 | 53 | 54 |
    55 |

    56 | 57 |

    58 |

    59 | Please verify your API key and ensure your WP101plugin.com account has access to the desired content.', 'wp101' ), 65 | esc_url( menu_page_url( 'wp101-settings', false ) ) 66 | ) 67 | ); 68 | } else { 69 | esc_html_e( 'Please contact a site administrator for further assistance.', 'wp101' ); 70 | } 71 | ?> 72 |

    73 |
    74 | 75 | 76 |
    77 | -------------------------------------------------------------------------------- /views/settings.php: -------------------------------------------------------------------------------- 1 | 28 | 29 |
    30 |

    31 | 32 | 33 | 34 |

    35 |

    36 |

    37 |

    38 | 39 | 40 | 41 |
    42 |

    43 |

    44 |
    define( 'WP101_API_KEY', '...' );
    45 |
    46 | 47 | 48 | 49 |
    > 50 |
    51 | 52 | 53 | 54 | 57 | 60 |
    55 | 56 | 58 | 59 |
    61 | 62 | 63 |
    64 |
    65 | 66 | 67 | 68 | 69 | 70 |
    71 | 72 | 75 | 81 |
    73 | 74 | 76 | 77 | 78 | 79 | 80 |
    82 |
    83 | 84 | 85 |
    86 | -------------------------------------------------------------------------------- /wp101.php: -------------------------------------------------------------------------------- 1 |