From d856983ed00ff46f53ec96eaed5b3cff9fd318d8 Mon Sep 17 00:00:00 2001 From: "Matthew M. Emma" Date: Fri, 7 Aug 2026 10:41:51 -0700 Subject: [PATCH 1/9] Set up project scaffolding and retire the 1.x plugin files Adds Composer with PHPCS (WordPress-Extra + WordPress-Docs), PHPUnit, a .distignore for release builds, editor config and the GPL-2.0 text. Removes index.php, includes/ and the 1.x stylesheet. They are replaced wholesale in the following commits: the 1.x entry point authenticated against the wrong Procore host and could never obtain a token, so there is nothing in it worth carrying forward. Co-Authored-By: Claude Opus 5 --- .distignore | 20 + .editorconfig | 16 + .gitattributes | 11 + .gitignore | 10 + LICENSE | 338 ++++++++++++ assets/css/procore-integration.css | 64 --- composer.json | 50 ++ includes/activation.php | 44 -- includes/styles.php | 52 -- index.php | 820 ----------------------------- phpcs.xml.dist | 90 ++++ phpunit.xml.dist | 18 + 12 files changed, 553 insertions(+), 980 deletions(-) create mode 100644 .distignore create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE delete mode 100644 assets/css/procore-integration.css create mode 100644 composer.json delete mode 100644 includes/activation.php delete mode 100644 includes/styles.php delete mode 100644 index.php create mode 100644 phpcs.xml.dist create mode 100644 phpunit.xml.dist diff --git a/.distignore b/.distignore new file mode 100644 index 0000000..59f7c90 --- /dev/null +++ b/.distignore @@ -0,0 +1,20 @@ +/.git +/.github +/.claude +/.wordpress-org +/docs +/tests +/node_modules +/vendor/bin +/build +.distignore +.editorconfig +.gitattributes +.gitignore +composer.json +composer.lock +phpcs.xml.dist +phpunit.xml.dist +CONTRIBUTING.md +SECURITY.md +CODE_OF_CONDUCT.md diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5153b80 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = tab + +[*.{md,yml,yaml,json}] +indent_style = space +indent_size = 2 + +[*.txt] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2b837d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +/.github export-ignore +/docs export-ignore +/tests export-ignore +/.distignore export-ignore +/.editorconfig export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/phpcs.xml.dist export-ignore +/phpunit.xml.dist export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d029f9b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/vendor/ +/node_modules/ +/build/ +/.phpunit.result.cache +/.phpcs.cache +composer.lock +*.log +.DS_Store +Thumbs.db +/.claude/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9efa6fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, see . + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Moe Ghoul, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/assets/css/procore-integration.css b/assets/css/procore-integration.css deleted file mode 100644 index 1bec953..0000000 --- a/assets/css/procore-integration.css +++ /dev/null @@ -1,64 +0,0 @@ -/* Procore Integration Styles */ -.procore-project { - margin-bottom: 30px; -} -.procore-project-details { - margin-top: 15px; -} -.procore-team-list, -.procore-drawings-list, -.procore-specifications-list { - list-style: none; - padding: 0; - margin: 0; -} -.procore-team-member, -.procore-drawing, -.procore-specification { - margin-bottom: 15px; - padding: 15px; - background: #f9f9f9; - border-radius: 4px; -} -.procore-member-name, -.procore-drawing-name, -.procore-spec-title { - font-weight: bold; - margin-bottom: 5px; -} -.procore-project-image img { - max-width: 100%; - height: auto; -} -.procore-project-data { - margin: 10px 0; -} -.procore-data-label { - font-weight: bold; -} -.procore-projects-table { - width: 100%; - border-collapse: collapse; - margin: 15px 0; -} -.procore-projects-table th, -.procore-projects-table td { - padding: 8px 12px; - text-align: left; - border-bottom: 1px solid #ddd; -} -.procore-projects-table th { - background-color: #f2f2f2; - font-weight: bold; -} -.procore-projects-table tr:hover { - background-color: #f5f5f5; -} -.procore-project-id { - font-family: monospace; - white-space: nowrap; -} -.error { - color: #d63638; - font-weight: bold; -} \ No newline at end of file diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..8697640 --- /dev/null +++ b/composer.json @@ -0,0 +1,50 @@ +{ + "name": "ibuilder/procorewp", + "description": "Connect WordPress to the Procore construction management platform and display project data with shortcodes, blocks and a REST proxy.", + "type": "wordpress-plugin", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "plugin", + "procore", + "construction", + "api" + ], + "homepage": "https://github.com/ibuilder/ProcoreWP", + "support": { + "issues": "https://github.com/ibuilder/ProcoreWP/issues", + "source": "https://github.com/ibuilder/ProcoreWP" + }, + "require": { + "php": ">=7.4" + }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.9", + "wp-coding-standards/wpcs": "^3.1", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpunit/phpunit": "^9.6" + }, + "autoload": { + "psr-4": { + "ProcoreWP\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "ProcoreWP\\Tests\\": "tests/" + } + }, + "config": { + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + }, + "sort-packages": true + }, + "scripts": { + "lint": "phpcs", + "lint:fix": "phpcbf", + "test": "phpunit", + "syntax": "find . -path ./vendor -prune -o -name '*.php' -print0 | xargs -0 -n1 php -l" + } +} diff --git a/includes/activation.php b/includes/activation.php deleted file mode 100644 index 2ae975c..0000000 --- a/includes/activation.php +++ /dev/null @@ -1,44 +0,0 @@ -settings = get_option('procore_integration_settings', [ - 'client_id' => '', - 'client_secret' => '', - 'api_url' => 'https://api.procore.com', - 'token' => '', - 'token_expires' => 0, - 'refresh_token' => '', - 'default_company_id' => '', - ]); - - // Initialize hooks - $this->init_hooks(); - } - - /** - * Get singleton instance - */ - public static function get_instance() { - if (null === self::$instance) { - self::$instance = new self(); - } - return self::$instance; - } - - /** - * Initialize WordPress hooks - */ - private function init_hooks() { - // Admin hooks - add_action('admin_menu', [$this, 'add_settings_page']); - add_action('admin_init', [$this, 'register_settings']); - - // Register shortcodes - add_shortcode('procore_project', [$this, 'project_shortcode']); - add_shortcode('procore_team', [$this, 'team_shortcode']); - add_shortcode('procore_featured_image', [$this, 'featured_image_shortcode']); - add_shortcode('procore_drawings', [$this, 'drawings_shortcode']); - add_shortcode('procore_specifications', [$this, 'specifications_shortcode']); - add_shortcode('procore_project_data', [$this, 'project_data_shortcode']); - add_shortcode('procore_project_list', [$this, 'project_list_shortcode']); - } - - /** - * Add settings page to WordPress admin - */ - public function add_settings_page() { - add_options_page( - 'Procore Integration Settings', - 'Procore Integration', - 'manage_options', - 'procore-integration', - [$this, 'render_settings_page'] - ); - } - - /** - * Register plugin settings - */ - public function register_settings() { - register_setting('procore_integration_settings_group', 'procore_integration_settings'); - - add_settings_section( - 'procore_integration_main_section', - 'API Configuration', - [$this, 'render_main_section'], - 'procore-integration' - ); - - add_settings_field( - 'procore_client_id', - 'Client ID', - [$this, 'render_client_id_field'], - 'procore-integration', - 'procore_integration_main_section' - ); - - add_settings_field( - 'procore_client_secret', - 'Client Secret', - [$this, 'render_client_secret_field'], - 'procore-integration', - 'procore_integration_main_section' - ); - - add_settings_field( - 'procore_api_url', - 'API URL', - [$this, 'render_api_url_field'], - 'procore-integration', - 'procore_integration_main_section' - ); - - add_settings_field( - 'procore_default_company_id', - 'Default Company ID', - [$this, 'render_default_company_id_field'], - 'procore-integration', - 'procore_integration_main_section' - ); - } - - /** - * Render main settings section - */ - public function render_main_section() { - echo '

Enter your Procore API credentials below. Learn more about Procore API authentication.

'; - } - - /** - * Render client ID field - */ - public function render_client_id_field() { - $value = isset($this->settings['client_id']) ? $this->settings['client_id'] : ''; - echo ''; - } - - /** - * Render client secret field - */ - public function render_client_secret_field() { - $value = isset($this->settings['client_secret']) ? $this->settings['client_secret'] : ''; - echo ''; - } - - /** - * Render API URL field - */ - public function render_api_url_field() { - $value = isset($this->settings['api_url']) ? $this->settings['api_url'] : 'https://api.procore.com'; - echo ''; - } - - /** - * Render default company ID field - */ - public function render_default_company_id_field() { - $value = isset($this->settings['default_company_id']) ? $this->settings['default_company_id'] : ''; - echo ''; - echo '

Enter your default Procore Company ID. This will be used if not specified in shortcodes.

'; - } - - /** - * Render settings page - */ - public function render_settings_page() { - if (!current_user_can('manage_options')) { - return; - } - - // Check if we need to test the connection - if (isset($_POST['test_connection'])) { - $this->test_connection(); - } - - // Display settings form - ?> -
-

-
- -
- -
- -

Test Connection

-

Click the button below to test your Procore API connection.

-
- -
- -
- -

Shortcode Reference

-

Use these shortcodes to display Procore information on your WordPress site:

-
    -
  • [procore_project_list company_id="123"] - Display a list of all project IDs and names
  • -
  • [procore_project id="123" company_id="123"] - Display project information
  • -
  • [procore_team id="123" company_id="123"] - Display project team members
  • -
  • [procore_featured_image id="123" company_id="123"] - Display project featured image
  • -
  • [procore_drawings id="123" company_id="123"] - Display project drawings
  • -
  • [procore_specifications id="123" company_id="123"] - Display project specifications
  • -
  • [procore_project_data id="123" company_id="123" field="field_name"] - Display specific project data
  • -
-
- get_token(); - - if (is_wp_error($result)) { - add_settings_error( - 'procore_integration', - 'connection_error', - 'Connection failed: ' . $result->get_error_message(), - 'error' - ); - } else { - add_settings_error( - 'procore_integration', - 'connection_success', - 'Connection successful! Your authentication token has been updated.', - 'success' - ); - } - } - - /** - * Get OAuth token from Procore - */ - private function get_token() { - // Check if we have a valid token - if (!empty($this->settings['token']) && $this->settings['token_expires'] > time()) { - return true; - } - - // Check if we have a refresh token - if (!empty($this->settings['refresh_token'])) { - return $this->refresh_token(); - } - - // Otherwise get a new token - $client_id = $this->settings['client_id']; - $client_secret = $this->settings['client_secret']; - $api_url = $this->settings['api_url']; - - if (empty($client_id) || empty($client_secret)) { - return new WP_Error('missing_credentials', 'Client ID and Client Secret are required'); - } - - $response = wp_remote_post($api_url . '/oauth/token', [ - 'body' => [ - 'grant_type' => 'client_credentials', - 'client_id' => $client_id, - 'client_secret' => $client_secret, - ], - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - ]); - - if (is_wp_error($response)) { - return $response; - } - - $body = json_decode(wp_remote_retrieve_body($response), true); - - if (isset($body['error'])) { - return new WP_Error('auth_error', $body['error_description'] ?? $body['error']); - } - - if (empty($body['access_token'])) { - return new WP_Error('invalid_response', 'Invalid response from Procore API'); - } - - // Update settings with new token - $this->settings['token'] = $body['access_token']; - $this->settings['token_expires'] = time() + ($body['expires_in'] ?? 7200); - $this->settings['refresh_token'] = $body['refresh_token'] ?? ''; - update_option('procore_integration_settings', $this->settings); - - return true; - } - - /** - * Refresh OAuth token - */ - private function refresh_token() { - $client_id = $this->settings['client_id']; - $client_secret = $this->settings['client_secret']; - $api_url = $this->settings['api_url']; - $refresh_token = $this->settings['refresh_token']; - - $response = wp_remote_post($api_url . '/oauth/token', [ - 'body' => [ - 'grant_type' => 'refresh_token', - 'refresh_token' => $refresh_token, - 'client_id' => $client_id, - 'client_secret' => $client_secret, - ], - 'headers' => [ - 'Content-Type' => 'application/x-www-form-urlencoded', - ], - ]); - - if (is_wp_error($response)) { - return $response; - } - - $body = json_decode(wp_remote_retrieve_body($response), true); - - if (isset($body['error'])) { - // If refresh token fails, clear it and try to get a new token - $this->settings['refresh_token'] = ''; - update_option('procore_integration_settings', $this->settings); - return $this->get_token(); - } - - if (empty($body['access_token'])) { - return new WP_Error('invalid_response', 'Invalid response from Procore API'); - } - - // Update settings with new token - $this->settings['token'] = $body['access_token']; - $this->settings['token_expires'] = time() + ($body['expires_in'] ?? 7200); - $this->settings['refresh_token'] = $body['refresh_token'] ?? ''; - update_option('procore_integration_settings', $this->settings); - - return true; - } - - /** - * Make a request to the Procore API - */ - private function api_request($endpoint, $method = 'GET', $data = null, $company_id = null) { - // Get token - $token_result = $this->get_token(); - if (is_wp_error($token_result)) { - return $token_result; - } - - $api_url = $this->settings['api_url']; - $token = $this->settings['token']; - - // Use company ID if provided, otherwise use default - if ($company_id === null) { - $company_id = $this->settings['default_company_id']; - } - - // Add company ID to endpoint if it's not already included - if (!empty($company_id) && strpos($endpoint, 'company_id=') === false) { - $endpoint .= (strpos($endpoint, '?') === false) ? '?' : '&'; - $endpoint .= 'company_id=' . $company_id; - } - - $args = [ - 'method' => $method, - 'headers' => [ - 'Authorization' => 'Bearer ' . $token, - 'Content-Type' => 'application/json', - ], - ]; - - if ($data !== null && in_array($method, ['POST', 'PUT', 'PATCH'])) { - $args['body'] = json_encode($data); - } - - $response = wp_remote_request($api_url . $endpoint, $args); - - if (is_wp_error($response)) { - return $response; - } - - $code = wp_remote_retrieve_response_code($response); - $body = json_decode(wp_remote_retrieve_body($response), true); - - if ($code >= 400) { - return new WP_Error( - 'api_error', - 'API Error (' . $code . '): ' . ($body['message'] ?? 'Unknown error') - ); - } - - return $body; - } - - /** - * Get project details - */ - private function get_project($project_id, $company_id = null) { - return $this->api_request('/rest/v1.0/projects/' . $project_id, 'GET', null, $company_id); - } - - /** - * Get project team members - */ - private function get_project_team($project_id, $company_id = null) { - return $this->api_request('/rest/v1.0/projects/' . $project_id . '/users', 'GET', null, $company_id); - } - - /** - * Get project drawings - */ - private function get_project_drawings($project_id, $company_id = null) { - return $this->api_request('/rest/v1.0/projects/' . $project_id . '/drawing_areas', 'GET', null, $company_id); - } - - /** - * Get project specifications - */ - private function get_project_specifications($project_id, $company_id = null) { - return $this->api_request('/rest/v1.0/projects/' . $project_id . '/specification_sections', 'GET', null, $company_id); - } - - /** - * Get project image - */ - private function get_project_image($project_id, $company_id = null) { - $project = $this->get_project($project_id, $company_id); - if (is_wp_error($project)) { - return $project; - } - - // Check if project has a logo - if (!empty($project['logo_url'])) { - return $project['logo_url']; - } - - return ''; - } - - /** - * Get all available projects - */ - private function get_projects($company_id = null) { - return $this->api_request('/rest/v1.0/projects', 'GET', null, $company_id); - } - - /** - * Project information shortcode - */ - public function project_shortcode($atts) { - $atts = shortcode_atts([ - 'id' => '', - 'company_id' => '', - ], $atts, 'procore_project'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - $project = $this->get_project($atts['id'], $atts['company_id']); - if (is_wp_error($project)) { - return '

Error: ' . esc_html($project->get_error_message()) . '

'; - } - - ob_start(); - ?> -
-

-
-

Address:

-

City:

-

State:

-

Zip:

-

Start Date:

-

Completion Date:

-

Status:

-
-
- '', - 'company_id' => '', - ], $atts, 'procore_team'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - $team = $this->get_project_team($atts['id'], $atts['company_id']); - if (is_wp_error($team)) { - return '

Error: ' . esc_html($team->get_error_message()) . '

'; - } - - if (empty($team)) { - return '

No team members found for this project.

'; - } - - ob_start(); - ?> -
-

Project Team

-
    - -
  • -
    -
    -
    -
  • - -
-
- '', - 'company_id' => '', - 'width' => '300', - 'height' => 'auto', - ], $atts, 'procore_featured_image'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - $image_url = $this->get_project_image($atts['id'], $atts['company_id']); - if (is_wp_error($image_url)) { - return '

Error: ' . esc_html($image_url->get_error_message()) . '

'; - } - - if (empty($image_url)) { - return '

No featured image available for this project.

'; - } - - ob_start(); - ?> -
- Project Image -
- '', - 'company_id' => '', - 'limit' => 10, - ], $atts, 'procore_drawings'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - $drawings = $this->get_project_drawings($atts['id'], $atts['company_id']); - if (is_wp_error($drawings)) { - return '

Error: ' . esc_html($drawings->get_error_message()) . '

'; - } - - if (empty($drawings)) { - return '

No drawings found for this project.

'; - } - - // Limit the number of drawings - $limit = intval($atts['limit']); - if ($limit > 0 && count($drawings) > $limit) { - $drawings = array_slice($drawings, 0, $limit); - } - - ob_start(); - ?> -
-

Project Drawings

-
    - -
  • -
    - -
    - -
  • - -
-
- '', - 'company_id' => '', - 'limit' => 10, - ], $atts, 'procore_specifications'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - $specs = $this->get_project_specifications($atts['id'], $atts['company_id']); - if (is_wp_error($specs)) { - return '

Error: ' . esc_html($specs->get_error_message()) . '

'; - } - - if (empty($specs)) { - return '

No specifications found for this project.

'; - } - - // Limit the number of specifications - $limit = intval($atts['limit']); - if ($limit > 0 && count($specs) > $limit) { - $specs = array_slice($specs, 0, $limit); - } - - ob_start(); - ?> -
-

Project Specifications

-
    - -
  • -
    -
    - -
    - -
  • - -
-
- '', - 'company_id' => '', - 'field' => '', - 'label' => '', - ], $atts, 'procore_project_data'); - - if (empty($atts['id'])) { - return '

Error: Project ID is required

'; - } - - if (empty($atts['field'])) { - return '

Error: Field name is required

'; - } - - $project = $this->get_project($atts['id'], $atts['company_id']); - if (is_wp_error($project)) { - return '

Error: ' . esc_html($project->get_error_message()) . '

'; - } - - $field = $atts['field']; - $value = isset($project[$field]) ? $project[$field] : ''; - - if (empty($value)) { - return '

No data available for field "' . esc_html($field) . '"

'; - } - - // Format value based on type - if (is_array($value)) { - $value = implode(', ', $value); - } elseif (is_bool($value)) { - $value = $value ? 'Yes' : 'No'; - } - - $label = !empty($atts['label']) ? $atts['label'] : ucwords(str_replace('_', ' ', $field)); - - ob_start(); - ?> -
- : - -
- 0, - 'company_id' => '', - 'show_details' => 'false', - 'active_only' => 'true', - 'sort_by' => 'name', // name, id, created_at - 'sort_order' => 'asc', // asc, desc - ], $atts, 'procore_project_list'); - - $projects = $this->get_projects($atts['company_id']); - if (is_wp_error($projects)) { - return '

Error: ' . esc_html($projects->get_error_message()) . '

'; - } - - if (empty($projects)) { - return '

No projects found.

'; - } - - // Filter projects by active status if needed - if ($atts['active_only'] === 'true') { - $projects = array_filter($projects, function($project) { - return isset($project['active']) && $project['active'] === true; - }); - } - - // Sort projects - $sort_by = $atts['sort_by']; - $sort_order = strtolower($atts['sort_order']) === 'desc' ? SORT_DESC : SORT_ASC; - - if (in_array($sort_by, ['name', 'id', 'created_at'])) { - $sort_column = array_column($projects, $sort_by); - array_multisort($sort_column, $sort_order, $projects); - } - - // Limit the number of projects if needed - $limit = intval($atts['limit']); - if ($limit > 0 && count($projects) > $limit) { - $projects = array_slice($projects, 0, $limit); - } - - $show_details = $atts['show_details'] === 'true'; - - ob_start(); - ?> -
-

Procore Projects

- - - - - - - - - - - - - - - - - - - - - - - - -
IDProject NameLocationStatus
- - - -
-
- + + Coding standards for the ProcoreWP plugin. + + . + + /vendor/* + /node_modules/* + /build/* + /docs/* + /tests/fixtures/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + /src/* + /tests/* + + + + + /templates/* + + + + /tests/* + + + + + /src/Cli/* + /tests/* + + + + + /tests/* + + + /tests/* + + + /tests/wp-shims.php + + + /tests/wp-shims.php + + + /tests/wp-shims.php + + + /tests/wp-shims.php + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..780f123 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,18 @@ + + + + + tests/unit + + + + + src + + + From e00e1f3316cb1b31a2843b896b28391f5eaded9a Mon Sep 17 00:00:00 2001 From: "Matthew M. Emma" Date: Fri, 7 Aug 2026 10:42:07 -0700 Subject: [PATCH 2/9] Add the API layer: auth, transport, caching and the endpoint registry Replaces the 1.x request code, which could not work against the live API. Authentication now posts to login.procore.com, Procore's actual token host. 1.x posted to api.procore.com, a different origin, so no token was ever issued on any install regardless of configuration. The company scope is now sent as the Procore-Company-Id header. 1.x appended it as a company_id query parameter, which most endpoints reject. Also here: - Both grants. Client Credentials for a Developer Managed Service Account, and Authorization Code on behalf of a Procore user. Refreshes are serialised behind a lock because Procore invalidates a refresh token the instant it is exchanged, so two concurrent front-end requests would otherwise lock the site out permanently. - Credentials and tokens encrypted at rest with AES-256-GCM keyed from the site's own salts, in a non-autoloaded option. 1.x kept all three as plaintext in an option read on every page request site-wide. wp-config.php constants take precedence and never reach the database. - Two-layer caching: a fresh entry per endpoint TTL, plus a long-lived stale copy served when Procore is unreachable, so an outage degrades a published page instead of blanking it. Backed by transients, so Redis and Memcached work with no configuration. - Rate-limit handling: records X-Rate-Limit-*, honours Retry-After, retries 429/503 with exponential backoff plus jitter, and opens a circuit breaker after five consecutive failures. - Pagination by following Link: rel="next", refusing any link pointing off the configured API host. 1.x silently returned page one only. - An endpoint allow-list. Nothing outside src/Api/Endpoints.php is callable, which is what later makes [procore_data] and the public REST proxy safe to expose. - Environment resolution for production, both sandboxes and custom regional or federal hosts, with tokens scoped per environment. Co-Authored-By: Claude Opus 5 --- procorewp.php | 63 +++ src/Api/Auth/AbstractAuth.php | 130 ++++++ src/Api/Auth/AuthInterface.php | 46 ++ src/Api/Auth/AuthorizationCode.php | 215 +++++++++ src/Api/Auth/ClientCredentials.php | 96 ++++ src/Api/Cache.php | 274 +++++++++++ src/Api/Client.php | 727 +++++++++++++++++++++++++++++ src/Api/Endpoints.php | 414 ++++++++++++++++ src/Api/Environment.php | 188 ++++++++ src/Api/TokenStore.php | 212 +++++++++ src/Support/Arr.php | 208 +++++++++ src/Support/Encryption.php | 193 ++++++++ src/Support/Format.php | 219 +++++++++ src/Support/Logger.php | 155 ++++++ 14 files changed, 3140 insertions(+) create mode 100644 procorewp.php create mode 100644 src/Api/Auth/AbstractAuth.php create mode 100644 src/Api/Auth/AuthInterface.php create mode 100644 src/Api/Auth/AuthorizationCode.php create mode 100644 src/Api/Auth/ClientCredentials.php create mode 100644 src/Api/Cache.php create mode 100644 src/Api/Client.php create mode 100644 src/Api/Endpoints.php create mode 100644 src/Api/Environment.php create mode 100644 src/Api/TokenStore.php create mode 100644 src/Support/Arr.php create mode 100644 src/Support/Encryption.php create mode 100644 src/Support/Format.php create mode 100644 src/Support/Logger.php diff --git a/procorewp.php b/procorewp.php new file mode 100644 index 0000000..ed791dc --- /dev/null +++ b/procorewp.php @@ -0,0 +1,63 @@ + $body Request parameters. + * @return array|\WP_Error Decoded token response, or an error. + */ + protected function request_token( array $body ) { + $client_id = Settings::client_id(); + $client_secret = Settings::client_secret(); + + if ( '' === $client_id || '' === $client_secret ) { + return new \WP_Error( + 'procorewp_missing_credentials', + __( 'Enter your Procore Client ID and Client Secret before connecting.', 'procorewp' ) + ); + } + + $body['client_id'] = $client_id; + $body['client_secret'] = $client_secret; + + $response = wp_remote_post( + Environment::token_url(), + array( + 'timeout' => 20, + 'sslverify' => true, + 'user-agent' => $this->user_agent(), + 'headers' => array( + 'Content-Type' => 'application/x-www-form-urlencoded', + 'Accept' => 'application/json', + ), + 'body' => $body, + ) + ); + + if ( is_wp_error( $response ) ) { + Logger::error( 'Token request transport failure.', array( 'error' => $response->get_error_message() ) ); + + return $response; + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $decoded = json_decode( (string) wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $decoded ) ) { + Logger::error( 'Token endpoint returned an unreadable body.', array( 'status' => $code ) ); + + return new \WP_Error( + 'procorewp_token_unreadable', + __( 'Procore returned an unreadable authentication response.', 'procorewp' ) + ); + } + + if ( $code >= 400 || isset( $decoded['error'] ) ) { + $message = (string) ( $decoded['error_description'] ?? $decoded['error'] ?? '' ); + + if ( '' === $message ) { + /* translators: %d: HTTP status code. */ + $message = sprintf( __( 'Procore rejected the authentication request (HTTP %d).', 'procorewp' ), $code ); + } + + Logger::error( + 'Token endpoint rejected the request.', + array( + 'status' => $code, + 'message' => $message, + ) + ); + + return new \WP_Error( 'procorewp_token_rejected', $message, array( 'status' => $code ) ); + } + + if ( empty( $decoded['access_token'] ) ) { + return new \WP_Error( + 'procorewp_token_missing', + __( 'Procore did not return an access token.', 'procorewp' ) + ); + } + + return $decoded; + } + + /** + * User agent sent with authentication requests. + * + * @return string User agent string. + */ + protected function user_agent(): string { + return sprintf( + 'ProcoreWP/%s; WordPress/%s; %s', + PROCOREWP_VERSION, + get_bloginfo( 'version' ), + home_url( '/' ) + ); + } + + /** + * Discard any stored token so the next request re-authenticates. + * + * @return void + */ + public function reset(): void { + TokenStore::clear(); + } +} diff --git a/src/Api/Auth/AuthInterface.php b/src/Api/Auth/AuthInterface.php new file mode 100644 index 0000000..e439427 --- /dev/null +++ b/src/Api/Auth/AuthInterface.php @@ -0,0 +1,46 @@ +request_token( + array( + 'grant_type' => 'refresh_token', + 'refresh_token' => $refresh_token, + 'redirect_uri' => Environment::redirect_uri(), + ) + ); + + if ( is_wp_error( $response ) ) { + // A rejected refresh token can never recover; force a reconnect. + if ( 'procorewp_token_rejected' === $response->get_error_code() ) { + TokenStore::clear(); + Logger::error( 'Refresh token rejected by Procore; the connection has been reset.' ); + + return new \WP_Error( + 'procorewp_reconnect_required', + __( 'The Procore connection has expired. Open Procore → Connection and reconnect.', 'procorewp' ) + ); + } + + return $response; + } + + TokenStore::store( $response, $this->grant_type() ); + Logger::info( 'Refreshed the Procore user access token.' ); + + return (string) $response['access_token']; + } finally { + TokenStore::release_lock(); + }//end try + } + + /** + * Build the Procore authorization URL and register its CSRF state. + * + * @return string Authorization URL. + */ + public function authorization_url(): string { + $state = wp_generate_password( 32, false ); + + set_transient( self::STATE_PREFIX . $state, get_current_user_id(), self::STATE_TTL ); + + return add_query_arg( + array( + 'client_id' => rawurlencode( Settings::client_id() ), + 'response_type' => 'code', + 'redirect_uri' => rawurlencode( Environment::redirect_uri() ), + 'state' => rawurlencode( $state ), + ), + Environment::authorize_url() + ); + } + + /** + * Exchange an authorization code for a token pair. + * + * @param string $code Authorization code returned by Procore. + * @param string $state CSRF state value returned by Procore. + * @return true|\WP_Error True on success, or an error describing the failure. + */ + public function exchange_code( string $code, string $state ) { + if ( '' === $state || false === get_transient( self::STATE_PREFIX . $state ) ) { + return new \WP_Error( + 'procorewp_bad_state', + __( 'The Procore authorization response could not be verified. Please start the connection again.', 'procorewp' ) + ); + } + + delete_transient( self::STATE_PREFIX . $state ); + + if ( '' === $code ) { + return new \WP_Error( + 'procorewp_missing_code', + __( 'Procore did not return an authorization code.', 'procorewp' ) + ); + } + + $response = $this->request_token( + array( + 'grant_type' => 'authorization_code', + 'code' => $code, + 'redirect_uri' => Environment::redirect_uri(), + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + TokenStore::store( $response, $this->grant_type() ); + Logger::info( 'Completed the Procore authorization code exchange.' ); + + return true; + } + + /** + * Whether the site currently holds a user connection. + * + * @return bool True when a refresh token is stored. + */ + public function is_connected(): bool { + return '' !== TokenStore::refresh_token(); + } + + /** + * Whether a client ID and secret are available. + * + * @return bool True when configured. + */ + public function is_configured(): bool { + return '' !== Settings::client_id() && '' !== Settings::client_secret(); + } + + /** + * Grant type identifier. + * + * @return string Grant type. + */ + public function grant_type(): string { + return 'authorization_code'; + } + + /** + * Human readable name. + * + * @return string Label. + */ + public function label(): string { + return __( 'User Account (Authorization Code)', 'procorewp' ); + } +} diff --git a/src/Api/Auth/ClientCredentials.php b/src/Api/Auth/ClientCredentials.php new file mode 100644 index 0000000..9cc48e4 --- /dev/null +++ b/src/Api/Auth/ClientCredentials.php @@ -0,0 +1,96 @@ +request_token( array( 'grant_type' => 'client_credentials' ) ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + TokenStore::store( $response, $this->grant_type() ); + Logger::info( 'Obtained a service account access token.' ); + + return (string) $response['access_token']; + } finally { + TokenStore::release_lock(); + } + } + + /** + * Whether a client ID and secret are available. + * + * @return bool True when configured. + */ + public function is_configured(): bool { + return '' !== Settings::client_id() && '' !== Settings::client_secret(); + } + + /** + * Grant type identifier. + * + * @return string Grant type. + */ + public function grant_type(): string { + return 'client_credentials'; + } + + /** + * Human readable name. + * + * @return string Label. + */ + public function label(): string { + return __( 'Service Account (Client Credentials)', 'procorewp' ); + } +} diff --git a/src/Api/Cache.php b/src/Api/Cache.php new file mode 100644 index 0000000..7ef092e --- /dev/null +++ b/src/Api/Cache.php @@ -0,0 +1,274 @@ + $args Request arguments. + * @param int $company Company identifier. + * @param int $project Project identifier. + * @return string Cache key. + */ + public static function key( string $slug, array $args, int $company, int $project ): string { + ksort( $args ); + + $signature = wp_json_encode( + array( + 'slug' => $slug, + 'args' => $args, + 'company' => $company, + 'project' => $project, + 'environment' => Environment::current(), + 'host' => Environment::api_host(), + ) + ); + + return md5( (string) $signature ); + } + + /** + * Read a fresh cache entry. + * + * @param string $key Cache key. + * @return mixed Cached value, or null on a miss. + */ + public static function get( string $key ) { + if ( ! self::enabled() ) { + return null; + } + + $value = get_transient( self::FRESH_PREFIX . $key ); + + return false === $value ? null : $value; + } + + /** + * Read the stale fallback copy of an entry. + * + * @param string $key Cache key. + * @return mixed Cached value, or null when no fallback exists. + */ + public static function get_stale( string $key ) { + $value = get_transient( self::STALE_PREFIX . $key ); + + return false === $value ? null : $value; + } + + /** + * Store a value in both the fresh and stale layers. + * + * @param string $key Cache key. + * @param mixed $value Value to store. + * @param int $ttl Fresh lifetime in seconds. + * @param string $group Cache group used for targeted purging. + * @return void + */ + public static function set( string $key, $value, int $ttl, string $group = 'default' ): void { + if ( ! self::enabled() ) { + return; + } + + $ttl = self::clamp_ttl( $ttl ); + + set_transient( self::FRESH_PREFIX . $key, $value, $ttl ); + set_transient( self::STALE_PREFIX . $key, $value, min( self::STALE_MAX_TTL, max( $ttl * 12, DAY_IN_SECONDS ) ) ); + + self::index( $group, $key ); + } + + /** + * Remove a single entry from both layers. + * + * @param string $key Cache key. + * @return void + */ + public static function delete( string $key ): void { + delete_transient( self::FRESH_PREFIX . $key ); + delete_transient( self::STALE_PREFIX . $key ); + } + + /** + * Purge cached responses. + * + * @param string $group Group to purge, or an empty string to purge everything. + * @return int Number of entries removed. + */ + public static function flush( string $group = '' ): int { + $index = self::read_index(); + $removed = 0; + + foreach ( $index as $indexed_group => $keys ) { + if ( '' !== $group && $indexed_group !== $group ) { + continue; + } + + foreach ( (array) $keys as $key ) { + self::delete( (string) $key ); + ++$removed; + } + + unset( $index[ $indexed_group ] ); + } + + if ( '' === $group ) { + delete_option( self::INDEX_OPTION ); + } else { + update_option( self::INDEX_OPTION, $index, false ); + } + + /** + * Fires after the Procore response cache has been purged. + * + * @since 2.0.0 + * + * @param string $group Purged group, or an empty string for a full purge. + * @param int $removed Number of entries removed. + */ + do_action( 'procorewp_cache_flushed', $group, $removed ); + + return $removed; + } + + /** + * Summarise the current cache contents for the admin Status screen. + * + * @return array Entry count keyed by group, plus a `total` key. + */ + public static function stats(): array { + $index = self::read_index(); + $stats = array(); + $total = 0; + + foreach ( $index as $group => $keys ) { + $count = count( (array) $keys ); + $stats[ $group ] = $count; + $total += $count; + } + + $stats['total'] = $total; + + return $stats; + } + + /** + * Whether caching is currently active. + * + * @return bool True when enabled. + */ + public static function enabled(): bool { + /** + * Filters whether Procore responses are cached. + * + * @since 2.0.0 + * + * @param bool $enabled Whether caching is active. + */ + return (bool) apply_filters( 'procorewp_cache_enabled', (bool) Settings::get( 'enable_cache', true ) ); + } + + /** + * Constrain a requested lifetime to the configured bounds. + * + * Shortcode authors may shorten a lifetime but never below the site-wide + * floor, so a single page cannot be configured into a rate-limit breach. + * + * @param int $ttl Requested lifetime in seconds. + * @return int Permitted lifetime in seconds. + */ + public static function clamp_ttl( int $ttl ): int { + $floor = (int) Settings::get( 'cache_floor', 60 ); + $floor = max( 30, $floor ); + + return max( $floor, min( $ttl, MONTH_IN_SECONDS ) ); + } + + /** + * Record a key against a group so it can be purged selectively. + * + * @param string $group Group name. + * @param string $key Cache key. + * @return void + */ + private static function index( string $group, string $key ): void { + $index = self::read_index(); + + if ( ! isset( $index[ $group ] ) || ! is_array( $index[ $group ] ) ) { + $index[ $group ] = array(); + } + + if ( in_array( $key, $index[ $group ], true ) ) { + return; + } + + $index[ $group ][] = $key; + + // Keep the index bounded; the oldest keys simply expire on their own. + if ( count( $index[ $group ] ) > 500 ) { + $index[ $group ] = array_slice( $index[ $group ], -500 ); + } + + update_option( self::INDEX_OPTION, $index, false ); + } + + /** + * Read the purge index. + * + * @return array> Index keyed by group. + */ + private static function read_index(): array { + $index = get_option( self::INDEX_OPTION, array() ); + + return is_array( $index ) ? $index : array(); + } +} diff --git a/src/Api/Client.php b/src/Api/Client.php new file mode 100644 index 0000000..9cad21e --- /dev/null +++ b/src/Api/Client.php @@ -0,0 +1,727 @@ + + */ + private $meta = array(); + + /** + * Retrieve the shared client instance. + * + * @return self Client instance. + */ + public static function instance(): self { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + /** + * Replace the shared instance. Intended for tests. + * + * @param self|null $client Replacement client, or null to reset. + * @return void + */ + public static function set_instance( ?self $client ): void { + self::$instance = $client; + } + + /** + * Override the HTTP transport. Intended for tests. + * + * @param callable|null $transport Callable matching `wp_remote_request( $url, $args )`. + * @return void + */ + public function set_transport( ?callable $transport ): void { + $this->transport = $transport; + } + + /** + * Override the authentication strategy. + * + * @param AuthInterface|null $auth Strategy, or null to resolve from settings. + * @return void + */ + public function set_auth( ?AuthInterface $auth ): void { + $this->auth = $auth; + } + + /** + * The active authentication strategy. + * + * @return AuthInterface Strategy resolved from the saved settings. + */ + public function auth(): AuthInterface { + if ( null === $this->auth ) { + $this->auth = 'authorization_code' === Settings::get( 'auth_mode', 'client_credentials' ) + ? new AuthorizationCode() + : new ClientCredentials(); + } + + return $this->auth; + } + + /** + * Metadata describing the most recent fetch. + * + * @return array Metadata including `cached`, `stale` and `total`. + */ + public function meta(): array { + return $this->meta; + } + + /** + * Fetch a registered endpoint. + * + * @param string $slug Endpoint slug from the registry. + * @param array $params Additional query parameters. + * @param array $options Behaviour overrides: `company_id`, `project_id`, + * `ttl`, `all`, `per_page`, `max_pages`, `bypass_cache`. + * @return mixed|\WP_Error Decoded response body, or an error. + */ + public function fetch( string $slug, array $params = array(), array $options = array() ) { + $definition = Endpoints::get( $slug ); + + if ( null === $definition ) { + return new \WP_Error( + 'procorewp_unknown_endpoint', + /* translators: %s: endpoint slug. */ + sprintf( __( 'Unknown Procore endpoint: %s', 'procorewp' ), $slug ) + ); + } + + $company = isset( $options['company_id'] ) ? absint( $options['company_id'] ) : Settings::default_company_id(); + $project = isset( $options['project_id'] ) ? absint( $options['project_id'] ) : 0; + + if ( Endpoints::SCOPE_NONE !== $definition['scope'] && $company <= 0 ) { + return new \WP_Error( + 'procorewp_missing_company', + __( 'No Procore company ID is available. Set a default company in Procore → Connection, or pass company_id.', 'procorewp' ) + ); + } + + $path = Endpoints::path( $slug, $company, $project ); + + if ( is_wp_error( $path ) ) { + return $path; + } + + $query = array_merge( Endpoints::context_query( $slug, $company, $project ), $params ); + + if ( ! empty( $definition['paginated'] ) ) { + $query['per_page'] = min( 2000, max( 1, (int) ( $options['per_page'] ?? Settings::get( 'per_page', 100 ) ) ) ); + $query['page'] = max( 1, (int) ( $query['page'] ?? 1 ) ); + } + + $ttl = isset( $options['ttl'] ) ? (int) $options['ttl'] : (int) $definition['ttl']; + $collect = ! empty( $options['all'] ) && ! empty( $definition['paginated'] ); + $cache_key = Cache::key( $slug . ( $collect ? ':all' : '' ), $query, $company, $project ); + $this->meta = array( + 'endpoint' => $slug, + 'cached' => false, + 'stale' => false, + 'total' => null, + ); + + if ( empty( $options['bypass_cache'] ) ) { + $cached = Cache::get( $cache_key ); + + if ( null !== $cached ) { + $this->meta['cached'] = true; + $this->meta['total'] = is_array( $cached ) ? count( $cached ) : null; + + return $cached; + } + } + + if ( $this->circuit_is_open() ) { + return $this->stale_or_error( + $cache_key, + new \WP_Error( + 'procorewp_circuit_open', + __( 'Procore requests are paused after repeated failures. They will resume automatically.', 'procorewp' ) + ) + ); + } + + $result = $collect + ? $this->collect_pages( $path, $query, $company, (int) ( $options['max_pages'] ?? self::MAX_PAGES ) ) + : $this->request( $path, $query, $company ); + + if ( is_wp_error( $result ) ) { + $this->record_failure(); + + return $this->stale_or_error( $cache_key, $result ); + } + + $this->record_success(); + + Cache::set( $cache_key, $result['body'], $ttl, $slug ); + + $this->meta['total'] = $result['total']; + + return $result['body']; + } + + /** + * Issue a single API request with retries. + * + * @param string $path API path beginning with a slash. + * @param array $query Query parameters. + * @param int $company Company identifier for the scope header. + * @return array{body: mixed, total: ?int, next: ?string}|\WP_Error Response, or an error. + */ + private function request( string $path, array $query, int $company ) { + $url = Environment::api_host() . $path; + + if ( ! empty( $query ) ) { + $url = add_query_arg( array_map( 'rawurlencode', array_map( 'strval', $query ) ), $url ); + } + + return $this->send( $url, $company ); + } + + /** + * Walk `Link: rel="next"` headers to assemble a complete result set. + * + * @param string $path API path beginning with a slash. + * @param array $query Query parameters. + * @param int $company Company identifier for the scope header. + * @param int $max_pages Hard page ceiling. + * @return array{body: mixed, total: ?int, next: ?string}|\WP_Error Combined response, or an error. + */ + private function collect_pages( string $path, array $query, int $company, int $max_pages ) { + $max_pages = max( 1, min( self::MAX_PAGES, $max_pages ) ); + $response = $this->request( $path, $query, $company ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $rows = is_array( $response['body'] ) ? $response['body'] : array(); + $next = $response['next']; + $pages = 1; + + while ( null !== $next && $pages < $max_pages ) { + $page = $this->send( $next, $company ); + + if ( is_wp_error( $page ) ) { + // Return what was gathered rather than discarding a partial set. + Logger::warning( + 'Pagination stopped early.', + array( + 'error' => $page->get_error_message(), + 'pages' => $pages, + ) + ); + break; + } + + if ( is_array( $page['body'] ) ) { + $rows = array_merge( $rows, $page['body'] ); + } + + $next = $page['next']; + ++$pages; + }//end while + + if ( null !== $next && $pages >= $max_pages ) { + Logger::warning( + 'Pagination hit the page ceiling; the result set is truncated.', + array( + 'pages' => $pages, + 'path' => $path, + ) + ); + } + + return array( + 'body' => $rows, + 'total' => count( $rows ), + 'next' => null, + ); + } + + /** + * Perform an HTTP GET with retry and backoff. + * + * @param string $url Absolute request URL. + * @param int $company Company identifier for the scope header. + * @return array{body: mixed, total: ?int, next: ?string}|\WP_Error Response, or an error. + */ + private function send( string $url, int $company ) { + $last_error = null; + + for ( $attempt = 1; $attempt <= self::MAX_ATTEMPTS; $attempt++ ) { + $token = $this->auth()->access_token(); + + if ( is_wp_error( $token ) ) { + return $token; + } + + $headers = array( + 'Authorization' => 'Bearer ' . $token, + 'Accept' => 'application/json', + ); + + // Mandatory for /me, /companies and every multi-region request. + if ( $company > 0 ) { + $headers['Procore-Company-Id'] = (string) $company; + } + + $args = array( + 'method' => 'GET', + 'timeout' => (int) Settings::get( 'request_timeout', 15 ), + 'sslverify' => true, + 'redirection' => 3, + 'user-agent' => sprintf( 'ProcoreWP/%s; WordPress/%s; %s', PROCOREWP_VERSION, get_bloginfo( 'version' ), home_url( '/' ) ), + 'headers' => $headers, + ); + + /** + * Filters the HTTP arguments for a Procore API request. + * + * @since 2.0.0 + * + * @param array $args Request arguments. + * @param string $url Request URL. + */ + $args = (array) apply_filters( 'procorewp_request_args', $args, $url ); + + $response = $this->dispatch( $url, $args ); + + if ( is_wp_error( $response ) ) { + $last_error = $response; + + if ( $attempt < self::MAX_ATTEMPTS ) { + $this->sleep( $this->backoff( $attempt ) ); + continue; + } + + return $response; + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + + $this->record_rate_limit( $response ); + + if ( 401 === $status && $attempt < self::MAX_ATTEMPTS ) { + // The token was revoked or rotated server-side; drop it and retry once. + TokenStore::clear(); + $last_error = new \WP_Error( 'procorewp_unauthorized', __( 'Procore rejected the access token.', 'procorewp' ) ); + continue; + } + + if ( 429 === $status || 503 === $status ) { + $wait = $this->retry_delay( $response, $attempt ); + + if ( $attempt < self::MAX_ATTEMPTS ) { + Logger::warning( + 'Procore throttled the request; backing off.', + array( + 'status' => $status, + 'wait' => $wait, + ) + ); + $this->sleep( $wait ); + continue; + } + + return new \WP_Error( + 'procorewp_rate_limited', + __( 'Procore is rate limiting this site. Cached data will be shown until the limit resets.', 'procorewp' ), + array( 'status' => $status ) + ); + }//end if + + return $this->parse( $response, $status ); + }//end for + + return $last_error instanceof \WP_Error + ? $last_error + : new \WP_Error( 'procorewp_request_failed', __( 'The Procore request could not be completed.', 'procorewp' ) ); + } + + /** + * Send the request through the configured transport. + * + * @param string $url Request URL. + * @param array $args Request arguments. + * @return array|\WP_Error Raw WordPress HTTP response. + */ + private function dispatch( string $url, array $args ) { + if ( is_callable( $this->transport ) ) { + return call_user_func( $this->transport, $url, $args ); + } + + return wp_remote_request( $url, $args ); + } + + /** + * Decode a successful response and extract pagination metadata. + * + * @param array $response Raw WordPress HTTP response. + * @param int $status HTTP status code. + * @return array{body: mixed, total: ?int, next: ?string}|\WP_Error Parsed response, or an error. + */ + private function parse( $response, int $status ) { + $raw = (string) wp_remote_retrieve_body( $response ); + $decoded = '' === $raw ? array() : json_decode( $raw, true ); + + if ( $status >= 400 ) { + $message = ''; + + if ( is_array( $decoded ) ) { + $message = (string) ( $decoded['message'] ?? $decoded['error'] ?? '' ); + + if ( '' === $message && isset( $decoded['errors'] ) ) { + $message = is_array( $decoded['errors'] ) ? implode( '; ', array_map( 'strval', $decoded['errors'] ) ) : (string) $decoded['errors']; + } + } + + if ( '' === $message ) { + /* translators: %d: HTTP status code. */ + $message = sprintf( __( 'Procore returned HTTP %d.', 'procorewp' ), $status ); + } + + Logger::error( + 'Procore API error.', + array( + 'status' => $status, + 'message' => $message, + ) + ); + + return new \WP_Error( 'procorewp_api_error', $message, array( 'status' => $status ) ); + }//end if + + if ( null === $decoded && '' !== $raw ) { + return new \WP_Error( 'procorewp_bad_json', __( 'Procore returned a response that could not be decoded.', 'procorewp' ) ); + } + + $total = wp_remote_retrieve_header( $response, 'total' ); + + return array( + 'body' => $decoded, + 'total' => ( '' === $total || null === $total ) ? null : (int) $total, + 'next' => $this->next_link( $response ), + ); + } + + /** + * Extract the `rel="next"` URL from a Link header. + * + * @param array $response Raw WordPress HTTP response. + * @return string|null Next page URL, or null when this is the final page. + */ + private function next_link( $response ): ?string { + $link = wp_remote_retrieve_header( $response, 'link' ); + + if ( is_array( $link ) ) { + $link = implode( ', ', $link ); + } + + if ( ! is_string( $link ) || '' === $link ) { + return null; + } + + if ( ! preg_match( '/<([^>]+)>\s*;\s*rel\s*=\s*"?next"?/i', $link, $matches ) ) { + return null; + } + + $url = esc_url_raw( trim( $matches[1] ), array( 'https' ) ); + + if ( '' === $url ) { + return null; + } + + // Never follow a Link header off the configured API host. + $host = wp_parse_url( $url, PHP_URL_HOST ); + $expected = wp_parse_url( Environment::api_host(), PHP_URL_HOST ); + + return ( $host && $host === $expected ) ? $url : null; + } + + /** + * Persist the rate-limit headers for the admin Status screen. + * + * @param array $response Raw WordPress HTTP response. + * @return void + */ + private function record_rate_limit( $response ): void { + $limit = wp_remote_retrieve_header( $response, 'x-rate-limit-limit' ); + $remaining = wp_remote_retrieve_header( $response, 'x-rate-limit-remaining' ); + $reset = wp_remote_retrieve_header( $response, 'x-rate-limit-reset' ); + + if ( '' === $limit && '' === $remaining ) { + return; + } + + update_option( + self::RATE_LIMIT_OPTION, + array( + 'limit' => (int) $limit, + 'remaining' => (int) $remaining, + 'reset' => (int) $reset, + 'updated' => time(), + ), + false + ); + } + + /** + * The most recent rate-limit snapshot. + * + * @return array Snapshot with `limit`, `remaining`, `reset` and `updated`. + */ + public static function rate_limit(): array { + $stored = get_option( self::RATE_LIMIT_OPTION, array() ); + + return wp_parse_args( + is_array( $stored ) ? $stored : array(), + array( + 'limit' => 0, + 'remaining' => 0, + 'reset' => 0, + 'updated' => 0, + ) + ); + } + + /** + * Determine how long to wait before retrying a throttled request. + * + * @param array $response Raw WordPress HTTP response. + * @param int $attempt Attempt number, starting at 1. + * @return int Seconds to wait. + */ + private function retry_delay( $response, int $attempt ): int { + $retry_after = (int) wp_remote_retrieve_header( $response, 'retry-after' ); + + if ( $retry_after > 0 ) { + return min( 10, $retry_after ); + } + + $reset = (int) wp_remote_retrieve_header( $response, 'x-rate-limit-reset' ); + + if ( $reset > time() ) { + return min( 10, $reset - time() ); + } + + return $this->backoff( $attempt ); + } + + /** + * Exponential backoff with jitter. + * + * @param int $attempt Attempt number, starting at 1. + * @return int Seconds to wait. + */ + private function backoff( int $attempt ): int { + return min( 8, (int) pow( 2, $attempt ) ) + wp_rand( 0, 1 ); + } + + /** + * Pause execution between retries. + * + * @param int $seconds Seconds to sleep. + * @return void + */ + private function sleep( int $seconds ): void { + if ( $seconds <= 0 ) { + return; + } + + /** + * Filters the retry pause. Return zero to disable sleeping, as tests do. + * + * @since 2.0.0 + * + * @param int $seconds Seconds to sleep. + */ + $seconds = (int) apply_filters( 'procorewp_retry_sleep', $seconds ); + + if ( $seconds > 0 ) { + sleep( min( 10, $seconds ) ); + } + } + + /** + * Serve the stale cached copy when one exists, otherwise return the error. + * + * @param string $cache_key Cache key. + * @param \WP_Error $error Error to return when no fallback exists. + * @return mixed|\WP_Error Stale payload, or the supplied error. + */ + private function stale_or_error( string $cache_key, \WP_Error $error ) { + $stale = Cache::get_stale( $cache_key ); + + if ( null === $stale ) { + return $error; + } + + $this->meta['cached'] = true; + $this->meta['stale'] = true; + + Logger::warning( + 'Serving a stale cached response after an API failure.', + array( 'error' => $error->get_error_message() ) + ); + + return $stale; + } + + /** + * Whether the circuit breaker is currently open. + * + * @return bool True when requests are paused. + */ + public function circuit_is_open(): bool { + $state = self::circuit_state(); + + return $state['open_until'] > time(); + } + + /** + * Current circuit breaker state. + * + * @return array State with `failures` and `open_until`. + */ + public static function circuit_state(): array { + $stored = get_option( self::CIRCUIT_OPTION, array() ); + + return wp_parse_args( + is_array( $stored ) ? $stored : array(), + array( + 'failures' => 0, + 'open_until' => 0, + ) + ); + } + + /** + * Reset the circuit breaker. + * + * @return void + */ + public static function reset_circuit(): void { + delete_option( self::CIRCUIT_OPTION ); + } + + /** + * Count a failed request towards the circuit breaker threshold. + * + * @return void + */ + private function record_failure(): void { + $state = self::circuit_state(); + ++$state['failures']; + + if ( $state['failures'] >= self::CIRCUIT_THRESHOLD ) { + $state['open_until'] = time() + self::CIRCUIT_COOLDOWN; + $state['failures'] = 0; + + Logger::error( 'Procore circuit breaker opened after repeated failures.' ); + } + + update_option( self::CIRCUIT_OPTION, $state, false ); + } + + /** + * Clear the failure counter after a successful request. + * + * @return void + */ + private function record_success(): void { + $state = self::circuit_state(); + + if ( 0 === $state['failures'] && 0 === $state['open_until'] ) { + return; + } + + self::reset_circuit(); + } +} diff --git a/src/Api/Endpoints.php b/src/Api/Endpoints.php new file mode 100644 index 0000000..1237d41 --- /dev/null +++ b/src/Api/Endpoints.php @@ -0,0 +1,414 @@ +>|null + */ + private static $registry = null; + + /** + * Retrieve the full endpoint registry. + * + * @return array> Registry keyed by endpoint slug. + */ + public static function all(): array { + if ( null !== self::$registry ) { + return self::$registry; + } + + $registry = array( + + /* Identity and company scope. */ + + 'me' => array( + 'label' => __( 'Authenticated account', 'procorewp' ), + 'path' => '/rest/v1.0/me', + 'scope' => self::SCOPE_NONE, + 'paginated' => false, + 'ttl' => 300, + 'permission' => __( 'None (identity endpoint)', 'procorewp' ), + 'fields' => array( 'id', 'login', 'name' ), + 'public' => false, + ), + 'companies' => array( + 'label' => __( 'Companies', 'procorewp' ), + 'path' => '/rest/v1.0/companies', + 'scope' => self::SCOPE_NONE, + 'paginated' => true, + 'ttl' => 3600, + 'permission' => __( 'Company Directory: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'name', 'is_active' ), + 'public' => false, + ), + 'offices' => array( + 'label' => __( 'Company offices', 'procorewp' ), + 'path' => '/rest/v1.0/companies/{company_id}/offices', + 'scope' => self::SCOPE_COMPANY, + 'paginated' => true, + 'ttl' => 21600, + 'permission' => __( 'Company Admin: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'name', 'address', 'city', 'state_code', 'zip', 'phone' ), + 'public' => true, + ), + 'company_vendors' => array( + 'label' => __( 'Company directory (vendors)', 'procorewp' ), + 'path' => '/rest/v1.0/vendors', + 'scope' => self::SCOPE_COMPANY, + 'query' => array( 'company_id' ), + 'paginated' => true, + 'ttl' => 21600, + 'permission' => __( 'Company Directory: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'name', 'city', 'state_code', 'business_phone', 'website' ), + 'public' => true, + ), + + /* Projects. */ + + 'projects' => array( + 'label' => __( 'Projects', 'procorewp' ), + 'path' => '/rest/v1.1/projects', + 'scope' => self::SCOPE_COMPANY, + 'query' => array( 'company_id' ), + 'paginated' => true, + 'ttl' => 900, + 'permission' => __( 'Company Admin / Project Directory: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'name', 'project_number', 'city', 'state_code', 'active', 'stage' ), + 'public' => true, + ), + 'project' => array( + 'label' => __( 'Project detail', 'procorewp' ), + 'path' => '/rest/v1.0/projects/{project_id}', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => false, + 'ttl' => 900, + 'permission' => __( 'Project Admin: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'name', 'address', 'city', 'state_code', 'zip', 'start_date', 'completion_date', 'active' ), + 'public' => true, + ), + 'project_users' => array( + 'label' => __( 'Project team', 'procorewp' ), + 'path' => '/rest/v1.0/projects/{project_id}/users', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => true, + 'ttl' => 1800, + 'permission' => __( 'Project Directory: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'name', 'job_title', 'vendor', 'email_address' ), + 'public' => true, + ), + 'project_vendors' => array( + 'label' => __( 'Project vendors', 'procorewp' ), + 'path' => '/rest/v1.0/projects/{project_id}/vendors', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => true, + 'ttl' => 1800, + 'permission' => __( 'Project Directory: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'name', 'city', 'state_code', 'business_phone' ), + 'public' => true, + ), + + /* Documents. */ + + 'drawing_areas' => array( + 'label' => __( 'Drawing areas', 'procorewp' ), + 'path' => '/rest/v1.0/drawing_areas', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 21600, + 'permission' => __( 'Drawings: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'name', 'description' ), + 'public' => true, + ), + 'drawing_revisions' => array( + 'label' => __( 'Drawing revisions', 'procorewp' ), + 'path' => '/rest/v1.0/drawing_revisions', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 21600, + 'permission' => __( 'Drawings: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'number', 'title', 'revision_number', 'received_date' ), + 'public' => true, + ), + 'specification_sections' => array( + 'label' => __( 'Specification sections', 'procorewp' ), + 'path' => '/rest/v1.0/specification_sections', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 21600, + 'permission' => __( 'Specifications: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'number', 'description', 'revision' ), + 'public' => true, + ), + + /* Project management tools. */ + + 'rfis' => array( + 'label' => __( 'RFIs', 'procorewp' ), + 'path' => '/rest/v1.0/projects/{project_id}/rfis', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => true, + 'ttl' => 600, + 'permission' => __( 'RFIs: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'number', 'subject', 'status', 'due_date' ), + 'public' => true, + ), + 'submittals' => array( + 'label' => __( 'Submittals', 'procorewp' ), + 'path' => '/rest/v1.0/projects/{project_id}/submittals', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => true, + 'ttl' => 600, + 'permission' => __( 'Submittals: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'number', 'title', 'status', 'due_date' ), + 'public' => true, + ), + 'punch_items' => array( + 'label' => __( 'Punch list', 'procorewp' ), + 'path' => '/rest/v1.0/punch_items', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 600, + 'permission' => __( 'Punch List: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'position', 'name', 'status', 'due_date' ), + 'public' => true, + ), + 'observations' => array( + 'label' => __( 'Observations', 'procorewp' ), + 'path' => '/rest/v1.0/observations/items', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 600, + 'permission' => __( 'Observations: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'number', 'name', 'status', 'due_date' ), + 'public' => true, + ), + 'daily_logs' => array( + 'label' => __( 'Daily construction report logs', 'procorewp' ), + 'path' => '/rest/v1.0/daily_construction_report_logs', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 1800, + 'permission' => __( 'Daily Log: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'date', 'comments', 'status' ), + 'public' => true, + ), + 'change_orders' => array( + 'label' => __( 'Change order packages', 'procorewp' ), + 'path' => '/rest/v1.0/change_order_packages', + 'scope' => self::SCOPE_PROJECT, + 'query' => array( 'project_id' ), + 'paginated' => true, + 'ttl' => 1800, + 'permission' => __( 'Change Orders: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'number', 'title', 'status', 'grand_total' ), + 'public' => true, + ), + 'milestones' => array( + 'label' => __( 'Schedule tasks', 'procorewp' ), + 'path' => '/rest/v1.0/projects/{project_id}/schedule/tasks', + 'scope' => self::SCOPE_PROJECT, + 'paginated' => true, + 'ttl' => 1800, + 'permission' => __( 'Schedule: Read Only', 'procorewp' ), + 'fields' => array( 'id', 'name', 'start_date', 'finish_date', 'percent_complete' ), + 'public' => true, + ), + ); + + /** + * Filters the registry of callable Procore endpoints. + * + * Use this to correct a path after a Procore resource version bump, or + * to register an additional endpoint for `[procore_data]`. + * + * @since 2.0.0 + * + * @param array> $registry Endpoint definitions keyed by slug. + */ + $registry = (array) apply_filters( 'procorewp_endpoints', $registry ); + + self::$registry = array_map( array( self::class, 'normalise' ), $registry ); + + return self::$registry; + } + + /** + * Retrieve a single endpoint definition. + * + * @param string $slug Endpoint slug. + * @return array|null Definition, or null when not registered. + */ + public static function get( string $slug ): ?array { + $all = self::all(); + + return $all[ $slug ] ?? null; + } + + /** + * Whether an endpoint slug is registered. + * + * @param string $slug Endpoint slug. + * @return bool True when registered. + */ + public static function exists( string $slug ): bool { + return null !== self::get( $slug ); + } + + /** + * Endpoints that may be reached by `[procore_data]` and the REST proxy. + * + * @return array> Public endpoint definitions. + */ + public static function public_endpoints(): array { + return array_filter( + self::all(), + static function ( array $definition ): bool { + return ! empty( $definition['public'] ); + } + ); + } + + /** + * Build the request path for an endpoint, substituting context identifiers. + * + * @param string $slug Endpoint slug. + * @param int $company Company identifier. + * @param int $project Project identifier. + * @return string|\WP_Error Resolved path, or an error when context is missing. + */ + public static function path( string $slug, int $company = 0, int $project = 0 ) { + $definition = self::get( $slug ); + + if ( null === $definition ) { + return new \WP_Error( + 'procorewp_unknown_endpoint', + /* translators: %s: endpoint slug. */ + sprintf( __( 'Unknown Procore endpoint: %s', 'procorewp' ), $slug ) + ); + } + + $path = (string) $definition['path']; + + if ( false !== strpos( $path, '{company_id}' ) ) { + if ( $company <= 0 ) { + return new \WP_Error( 'procorewp_missing_company', __( 'A Procore company ID is required for this request.', 'procorewp' ) ); + } + + $path = str_replace( '{company_id}', (string) $company, $path ); + } + + if ( false !== strpos( $path, '{project_id}' ) ) { + if ( $project <= 0 ) { + return new \WP_Error( 'procorewp_missing_project', __( 'A Procore project ID is required for this request.', 'procorewp' ) ); + } + + $path = str_replace( '{project_id}', (string) $project, $path ); + } + + if ( self::SCOPE_PROJECT === $definition['scope'] && $project <= 0 ) { + return new \WP_Error( 'procorewp_missing_project', __( 'A Procore project ID is required for this request.', 'procorewp' ) ); + } + + return $path; + } + + /** + * Query arguments an endpoint requires in addition to caller-supplied ones. + * + * @param string $slug Endpoint slug. + * @param int $company Company identifier. + * @param int $project Project identifier. + * @return array Query arguments. + */ + public static function context_query( string $slug, int $company = 0, int $project = 0 ): array { + $definition = self::get( $slug ); + + if ( null === $definition ) { + return array(); + } + + $query = array(); + + foreach ( (array) $definition['query'] as $key ) { + if ( 'company_id' === $key && $company > 0 ) { + $query['company_id'] = $company; + } + + if ( 'project_id' === $key && $project > 0 ) { + $query['project_id'] = $project; + } + } + + return $query; + } + + /** + * Apply defaults to a registry entry. + * + * @param array $definition Raw definition. + * @return array Definition with defaults applied. + */ + private static function normalise( array $definition ): array { + return wp_parse_args( + $definition, + array( + 'label' => '', + 'path' => '', + 'scope' => self::SCOPE_NONE, + 'query' => array(), + 'paginated' => false, + 'ttl' => 900, + 'permission' => '', + 'fields' => array(), + 'public' => false, + ) + ); + } +} diff --git a/src/Api/Environment.php b/src/Api/Environment.php new file mode 100644 index 0000000..761631e --- /dev/null +++ b/src/Api/Environment.php @@ -0,0 +1,188 @@ +> + */ + private const ENVIRONMENTS = array( + 'production' => array( + 'login' => 'https://login.procore.com', + 'api' => 'https://api.procore.com', + ), + 'sandbox' => array( + 'login' => 'https://login-sandbox.procore.com', + 'api' => 'https://sandbox.procore.com', + ), + 'sandbox_monthly' => array( + 'login' => 'https://login-sandbox-monthly.procore.com', + 'api' => 'https://api-monthly.procore.com', + ), + ); + + /** + * Environment identifiers and their translated labels. + * + * @return array Map of value to label. + */ + public static function choices(): array { + return array( + 'production' => __( 'Production', 'procorewp' ), + 'sandbox' => __( 'Developer Sandbox', 'procorewp' ), + 'sandbox_monthly' => __( 'Monthly Sandbox', 'procorewp' ), + 'custom' => __( 'Custom (regional or federal zone)', 'procorewp' ), + ); + } + + /** + * The currently configured environment identifier. + * + * @return string Environment key. + */ + public static function current(): string { + $value = (string) Settings::get( 'environment', 'production' ); + + return array_key_exists( $value, self::choices() ) ? $value : 'production'; + } + + /** + * Base URL of the OAuth login host, without a trailing slash. + * + * @return string Login host URL. + */ + public static function login_host(): string { + $environment = self::current(); + + if ( 'custom' === $environment ) { + return self::normalise( (string) Settings::get( 'custom_login_url', '' ), self::ENVIRONMENTS['production']['login'] ); + } + + /** + * Filters the Procore OAuth login host. + * + * @since 2.0.0 + * + * @param string $host Login host URL without a trailing slash. + * @param string $environment Active environment identifier. + */ + return (string) apply_filters( 'procorewp_login_host', self::ENVIRONMENTS[ $environment ]['login'], $environment ); + } + + /** + * Base URL of the REST API host, without a trailing slash. + * + * @return string API host URL. + */ + public static function api_host(): string { + $environment = self::current(); + + if ( 'custom' === $environment ) { + return self::normalise( (string) Settings::get( 'custom_api_url', '' ), self::ENVIRONMENTS['production']['api'] ); + } + + /** + * Filters the Procore REST API host. + * + * @since 2.0.0 + * + * @param string $host API host URL without a trailing slash. + * @param string $environment Active environment identifier. + */ + return (string) apply_filters( 'procorewp_api_host', self::ENVIRONMENTS[ $environment ]['api'], $environment ); + } + + /** + * Full URL of the OAuth token endpoint. + * + * @return string Token endpoint URL. + */ + public static function token_url(): string { + return self::login_host() . '/oauth/token'; + } + + /** + * Full URL of the OAuth authorization endpoint. + * + * @return string Authorize endpoint URL. + */ + public static function authorize_url(): string { + return self::login_host() . '/oauth/authorize'; + } + + /** + * The redirect URI this site presents during the Authorization Code flow. + * + * This exact value must be registered in the Procore Developer Portal. + * + * @return string Redirect URI. + */ + public static function redirect_uri(): string { + /** + * Filters the OAuth redirect URI registered with Procore. + * + * @since 2.0.0 + * + * @param string $uri Redirect URI. + */ + return (string) apply_filters( + 'procorewp_redirect_uri', + admin_url( 'admin-post.php?action=procorewp_oauth_callback' ) + ); + } + + /** + * Whether the active environment points at production Procore data. + * + * @return bool True for production. + */ + public static function is_production(): bool { + return 'production' === self::current(); + } + + /** + * Validate and normalise a user-supplied host URL. + * + * @param string $url Candidate URL. + * @param string $fallback Value used when the candidate is unusable. + * @return string Normalised URL without a trailing slash. + */ + private static function normalise( string $url, string $fallback ): string { + $url = trim( $url ); + + if ( '' === $url ) { + return $fallback; + } + + $url = esc_url_raw( $url, array( 'https' ) ); + $parts = wp_parse_url( $url ); + + if ( empty( $parts['host'] ) || empty( $parts['scheme'] ) || 'https' !== $parts['scheme'] ) { + return $fallback; + } + + return 'https://' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); + } +} diff --git a/src/Api/TokenStore.php b/src/Api/TokenStore.php new file mode 100644 index 0000000..f366cfd --- /dev/null +++ b/src/Api/TokenStore.php @@ -0,0 +1,212 @@ + Token payload with decrypted values. + */ + public static function all(): array { + $stored = get_option( self::OPTION, array() ); + + if ( ! is_array( $stored ) ) { + $stored = array(); + } + + $payload = wp_parse_args( + $stored, + array( + 'access_token' => '', + 'refresh_token' => '', + 'expires_at' => 0, + 'obtained_at' => 0, + 'grant_type' => '', + 'environment' => '', + ) + ); + + $payload['access_token'] = Encryption::decrypt( (string) $payload['access_token'] ); + $payload['refresh_token'] = Encryption::decrypt( (string) $payload['refresh_token'] ); + $payload['expires_at'] = (int) $payload['expires_at']; + $payload['obtained_at'] = (int) $payload['obtained_at']; + + return $payload; + } + + /** + * Persist a token response from Procore. + * + * @param array $response Decoded `/oauth/token` response body. + * @param string $grant_type Grant type that produced the token. + * @return void + */ + public static function store( array $response, string $grant_type ): void { + $expires_in = isset( $response['expires_in'] ) ? (int) $response['expires_in'] : 5400; + $expires_in = max( 60, $expires_in ); + + $payload = array( + 'access_token' => Encryption::encrypt( (string) ( $response['access_token'] ?? '' ) ), + 'refresh_token' => Encryption::encrypt( (string) ( $response['refresh_token'] ?? '' ) ), + 'expires_at' => time() + $expires_in - self::EXPIRY_MARGIN, + 'obtained_at' => time(), + 'grant_type' => $grant_type, + 'environment' => Environment::current(), + ); + + update_option( self::OPTION, $payload, false ); + + /** + * Fires after a Procore access token has been stored. + * + * @since 2.0.0 + * + * @param string $grant_type Grant type that produced the token. + * @param int $expires_at Unix timestamp at which the token should be considered expired. + */ + do_action( 'procorewp_token_stored', $grant_type, (int) $payload['expires_at'] ); + } + + /** + * Discard all stored tokens. + * + * @return void + */ + public static function clear(): void { + delete_option( self::OPTION ); + self::release_lock(); + } + + /** + * Whether a usable, unexpired access token is stored for this environment. + * + * @return bool True when the stored token can be used as-is. + */ + public static function has_valid_token(): bool { + $payload = self::all(); + + if ( '' === $payload['access_token'] ) { + return false; + } + + // Tokens are not portable between production and sandbox. + if ( '' !== $payload['environment'] && Environment::current() !== $payload['environment'] ) { + return false; + } + + return $payload['expires_at'] > time(); + } + + /** + * The stored access token, without validity checks. + * + * @return string Access token, or an empty string when none is stored. + */ + public static function access_token(): string { + return (string) self::all()['access_token']; + } + + /** + * The stored refresh token. + * + * @return string Refresh token, or an empty string when none is stored. + */ + public static function refresh_token(): string { + return (string) self::all()['refresh_token']; + } + + /** + * Attempt to acquire the refresh lock. + * + * @return bool True when this process owns the lock. + */ + public static function acquire_lock(): bool { + // wp_cache_add() is atomic on a persistent object cache; the transient + // check below covers sites without one. + if ( wp_cache_add( self::LOCK_KEY, 1, 'procorewp', self::LOCK_TTL ) ) { + set_transient( self::LOCK_KEY, 1, self::LOCK_TTL ); + + return true; + } + + if ( false !== get_transient( self::LOCK_KEY ) ) { + return false; + } + + set_transient( self::LOCK_KEY, 1, self::LOCK_TTL ); + + return true; + } + + /** + * Release the refresh lock. + * + * @return void + */ + public static function release_lock(): void { + wp_cache_delete( self::LOCK_KEY, 'procorewp' ); + delete_transient( self::LOCK_KEY ); + } + + /** + * Wait briefly for another process to finish refreshing. + * + * @param int $attempts Number of 250ms polls before giving up. + * @return bool True when a valid token became available. + */ + public static function wait_for_refresh( int $attempts = 8 ): bool { + for ( $i = 0; $i < $attempts; $i++ ) { + usleep( 250000 ); + + if ( self::has_valid_token() ) { + return true; + } + } + + return false; + } +} diff --git a/src/Support/Arr.php b/src/Support/Arr.php new file mode 100644 index 0000000..85dc7bc --- /dev/null +++ b/src/Support/Arr.php @@ -0,0 +1,208 @@ +$segment ) ) { + $current = $current->$segment; + continue; + } + + return $default_value; + } + + return $current; + } + + /** + * Read a value and coerce it to a display-ready string. + * + * @param mixed $subject Array or object to read from. + * @param string $path Dot-delimited path. + * @param string $default_value Value returned when the path is missing or empty. + * @return string Display string. + */ + public static function str( $subject, string $path, string $default_value = '' ): string { + $value = self::get( $subject, $path ); + + return self::stringify( $value, $default_value ); + } + + /** + * Convert an arbitrary API value into a display string. + * + * @param mixed $value Raw value. + * @param string $default_value Value returned when empty. + * @return string Display string. + */ + public static function stringify( $value, string $default_value = '' ): string { + if ( null === $value || '' === $value ) { + return $default_value; + } + + if ( is_bool( $value ) ) { + return $value ? __( 'Yes', 'procorewp' ) : __( 'No', 'procorewp' ); + } + + if ( is_scalar( $value ) ) { + return (string) $value; + } + + if ( is_array( $value ) ) { + // Common Procore shape: a related record exposed as an object with a label. + foreach ( array( 'name', 'label', 'title', 'full_name', 'login' ) as $label_key ) { + if ( isset( $value[ $label_key ] ) && is_scalar( $value[ $label_key ] ) ) { + return (string) $value[ $label_key ]; + } + } + + $parts = array(); + + foreach ( $value as $item ) { + $rendered = self::stringify( $item ); + + if ( '' !== $rendered ) { + $parts[] = $rendered; + } + } + + return empty( $parts ) ? $default_value : implode( ', ', $parts ); + } + + return $default_value; + } + + /** + * Sort a list of associative rows by a column, tolerating missing keys. + * + * `array_multisort()` on `array_column()` output — the approach used by + * ProcoreWP 1.x — silently reorders rows when any record lacks the column. + * + * Records with no value for the column always sort last, in both + * directions, so reversing the order never promotes empty rows to the top. + * + * @param array $rows Rows to sort. + * @param string $column Column key, dot notation supported. + * @param string $direction `asc` or `desc`. + * @return array Sorted rows. + */ + public static function sort_by( array $rows, string $column, string $direction = 'asc' ): array { + if ( '' === $column ) { + return $rows; + } + + $descending = 'desc' === strtolower( $direction ); + + usort( + $rows, + static function ( $a, $b ) use ( $column, $descending ) { + $left = self::get( $a, $column ); + $right = self::get( $b, $column ); + + $left_empty = ( null === $left || '' === $left ); + $right_empty = ( null === $right || '' === $right ); + + if ( $left_empty || $right_empty ) { + if ( $left_empty && $right_empty ) { + return 0; + } + + return $left_empty ? 1 : -1; + } + + if ( is_numeric( $left ) && is_numeric( $right ) ) { + $result = ( (float) $left ) <=> ( (float) $right ); + } else { + $result = strcasecmp( self::stringify( $left ), self::stringify( $right ) ); + } + + return $descending ? -$result : $result; + } + ); + + return $rows; + } + + /** + * Reduce each row to an allow-listed set of fields. + * + * @param array $rows Rows to project. + * @param array $fields Field paths to retain. + * @return array> Projected rows. + */ + public static function pluck_fields( array $rows, array $fields ): array { + if ( empty( $fields ) ) { + return $rows; + } + + $projected = array(); + + foreach ( $rows as $row ) { + $item = array(); + + foreach ( $fields as $field ) { + $item[ $field ] = self::get( $row, $field ); + } + + $projected[] = $item; + } + + return $projected; + } + + /** + * Determine whether a value looks like a list of records. + * + * @param mixed $value Value to test. + * @return bool True for a zero-indexed array. + */ + public static function is_list( $value ): bool { + if ( ! is_array( $value ) ) { + return false; + } + + if ( array() === $value ) { + return true; + } + + return array_keys( $value ) === range( 0, count( $value ) - 1 ); + } +} diff --git a/src/Support/Encryption.php b/src/Support/Encryption.php new file mode 100644 index 0000000..5403d43 --- /dev/null +++ b/src/Support/Encryption.php @@ -0,0 +1,193 @@ + $column Column definition with `key` and `format`. + * @param bool $show_email Whether the shortcode opted in to email output. + * @return string Escaped HTML. + */ + public static function cell( $record, array $column, bool $show_email = false ): string { + $key = (string) ( $column['key'] ?? '' ); + $format = (string) ( $column['format'] ?? 'text' ); + + if ( '__location' === $key ) { + return esc_html( self::location( $record ) ); + } + + $raw = Arr::get( $record, $key ); + + switch ( $format ) { + case 'date': + return esc_html( self::date( $raw, '—' ) ); + + case 'currency': + return esc_html( self::currency( $raw, '—' ) ); + + case 'percent': + return is_numeric( $raw ) + ? esc_html( number_format_i18n( (float) $raw, 0 ) . '%' ) + : '—'; + + case 'status': + $active = null === $raw ? null : (bool) $raw; + + if ( null === $active ) { + return '—'; + } + + return sprintf( + '%2$s', + $active ? 'active' : 'inactive', + esc_html( $active ? __( 'Active', 'procorewp' ) : __( 'Inactive', 'procorewp' ) ) + ); + + case 'email': + $email = self::email( $raw, $show_email ); + + if ( '' === $email ) { + return ''; + } + + return sprintf( + '%2$s', + esc_url( 'mailto:' . $email ), + esc_html( $email ) + ); + + case 'url': + $url = esc_url_raw( Arr::stringify( $raw ), array( 'http', 'https' ) ); + + if ( '' === $url ) { + return '—'; + } + + $host = wp_parse_url( $url, PHP_URL_HOST ); + + return sprintf( + '%2$s', + esc_url( $url ), + esc_html( is_string( $host ) && '' !== $host ? $host : $url ) + ); + + default: + $value = Arr::stringify( $raw ); + + return '' === $value ? '—' : esc_html( $value ); + }//end switch + } + + /** + * Compose a CSS class attribute from a base class and user-supplied extras. + * + * @param string $base Base class list. + * @param string $extra Additional classes from a shortcode attribute. + * @return string Sanitized class list. + */ + public static function classes( string $base, string $extra = '' ): string { + $classes = preg_split( '/\s+/', $base . ' ' . $extra, -1, PREG_SPLIT_NO_EMPTY ); + + if ( ! is_array( $classes ) ) { + return $base; + } + + $classes = array_map( 'sanitize_html_class', $classes ); + $classes = array_filter( array_unique( $classes ) ); + + return implode( ' ', $classes ); + } +} diff --git a/src/Support/Logger.php b/src/Support/Logger.php new file mode 100644 index 0000000..48c2998 --- /dev/null +++ b/src/Support/Logger.php @@ -0,0 +1,155 @@ + $context Additional context. + * @return void + */ + public static function error( string $message, array $context = array() ): void { + self::log( 'error', $message, $context ); + } + + /** + * Record a warning-level event. + * + * @param string $message Human readable message. + * @param array $context Additional context. + * @return void + */ + public static function warning( string $message, array $context = array() ): void { + self::log( 'warning', $message, $context ); + } + + /** + * Record an informational event. + * + * @param string $message Human readable message. + * @param array $context Additional context. + * @return void + */ + public static function info( string $message, array $context = array() ): void { + self::log( 'info', $message, $context ); + } + + /** + * Retrieve the retained log entries, newest first. + * + * @return array> Log entries. + */ + public static function entries(): array { + $entries = get_option( self::OPTION, array() ); + + return is_array( $entries ) ? $entries : array(); + } + + /** + * Discard all retained log entries. + * + * @return void + */ + public static function clear(): void { + delete_option( self::OPTION ); + } + + /** + * Append an entry to the ring buffer and, when enabled, the debug log. + * + * @param string $level Severity level. + * @param string $message Human readable message. + * @param array $context Additional context. + * @return void + */ + private static function log( string $level, string $message, array $context ): void { + if ( ! Settings::get( 'enable_logging', false ) ) { + return; + } + + $entry = array( + 'time' => time(), + 'level' => $level, + 'message' => $message, + 'context' => self::redact( $context ), + ); + + $entries = self::entries(); + array_unshift( $entries, $entry ); + $entries = array_slice( $entries, 0, self::MAX_ENTRIES ); + + update_option( self::OPTION, $entries, false ); + + if ( defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { + $suffix = empty( $entry['context'] ) ? '' : ' ' . wp_json_encode( $entry['context'] ); + + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated behind WP_DEBUG_LOG and an explicit opt-in setting. + error_log( sprintf( '[ProcoreWP][%s] %s%s', $level, $message, $suffix ) ); + } + } + + /** + * Strip anything that resembles a credential from a context array. + * + * @param array $context Raw context. + * @return array Redacted context. + */ + private static function redact( array $context ): array { + $sensitive = array( 'client_secret', 'access_token', 'refresh_token', 'authorization', 'code', 'secret', 'password' ); + $clean = array(); + + foreach ( $context as $key => $value ) { + $normalised = strtolower( (string) $key ); + + if ( in_array( $normalised, $sensitive, true ) ) { + $clean[ $key ] = '[redacted]'; + continue; + } + + if ( is_array( $value ) ) { + $clean[ $key ] = self::redact( $value ); + continue; + } + + if ( is_scalar( $value ) || null === $value ) { + $clean[ $key ] = is_string( $value ) ? mb_substr( $value, 0, 500 ) : $value; + continue; + } + + $clean[ $key ] = '[object]'; + } + + return $clean; + } +} From 518c766dd073b084efc416ff64ea079e45c30b40 Mon Sep 17 00:00:00 2001 From: "Matthew M. Emma" Date: Fri, 7 Aug 2026 10:42:22 -0700 Subject: [PATCH 3/9] Add shortcodes, templates and the settings store Every 1.x shortcode tag is preserved, and the legacy id, sort_by, sort_order and show_details attributes still work, so pages written for the old plugin render unchanged. Eleven new shortcodes cover RFIs, submittals, punch lists, observations, daily logs, change orders, schedule tasks, vendors, offices, project locations, plus a generic allow-listed reader. Behaviour fixes over 1.x: - Sorting no longer corrupts result sets. array_multisort() over array_column() misaligns rows as soon as any record lacks the sort column; records with no value now sort last in both directions and stay attached to their own row. - Sparse Procore payloads no longer emit PHP warnings. - The stylesheet loads only on pages that actually contain plugin output, rather than on every front-end request. Security and privacy: - register_setting() has a sanitize_callback. 1.x had none, so an arbitrary array could be written to the option. - Email addresses are suppressed by default and need two independent opt-ins to publish; even then they pass through antispambot(). 1.x published every team member's address on a public page by default. - Procore error messages name accounts and permissions, so they reach administrators only; visitors get a neutral notice. - [procore_project_data] reads from a field allow-list rather than any key in the payload. - All cell content goes through Format::cell(), which escapes everything it returns, keeping the escaping guarantee auditable in one place. Markup now lives in templates/ and is overridable from yourtheme/procorewp/. 1.x told users to edit files inside the plugin directory, which lost their work on every update; custom CSS moves to a sanitized admin setting printed via wp_add_inline_style(). uninstall.php removes credentials, tokens, cache and options unless the site opts to keep its data. Co-Authored-By: Claude Opus 5 --- assets/css/procorewp.css | 229 ++++++++ src/Admin/Settings.php | 309 ++++++++++ src/Frontend/Assets.php | 135 +++++ src/Frontend/Renderer.php | 120 ++++ src/Frontend/Shortcodes/AbstractShortcode.php | 394 +++++++++++++ .../Shortcodes/CollectionShortcode.php | 197 +++++++ src/Frontend/Shortcodes/DataShortcode.php | 92 +++ src/Frontend/Shortcodes/FieldShortcode.php | 183 ++++++ src/Frontend/Shortcodes/ImageShortcode.php | 106 ++++ src/Frontend/Shortcodes/MapShortcode.php | 107 ++++ src/Frontend/Shortcodes/RecordShortcode.php | 47 ++ src/Frontend/Shortcodes/Registrar.php | 550 ++++++++++++++++++ src/Plugin.php | 198 +++++++ templates/collection.php | 61 ++ templates/field.php | 30 + templates/image.php | 42 ++ templates/map.php | 65 +++ templates/record.php | 45 ++ uninstall.php | 64 ++ 19 files changed, 2974 insertions(+) create mode 100644 assets/css/procorewp.css create mode 100644 src/Admin/Settings.php create mode 100644 src/Frontend/Assets.php create mode 100644 src/Frontend/Renderer.php create mode 100644 src/Frontend/Shortcodes/AbstractShortcode.php create mode 100644 src/Frontend/Shortcodes/CollectionShortcode.php create mode 100644 src/Frontend/Shortcodes/DataShortcode.php create mode 100644 src/Frontend/Shortcodes/FieldShortcode.php create mode 100644 src/Frontend/Shortcodes/ImageShortcode.php create mode 100644 src/Frontend/Shortcodes/MapShortcode.php create mode 100644 src/Frontend/Shortcodes/RecordShortcode.php create mode 100644 src/Frontend/Shortcodes/Registrar.php create mode 100644 src/Plugin.php create mode 100644 templates/collection.php create mode 100644 templates/field.php create mode 100644 templates/image.php create mode 100644 templates/map.php create mode 100644 templates/record.php create mode 100644 uninstall.php diff --git a/assets/css/procorewp.css b/assets/css/procorewp.css new file mode 100644 index 0000000..e2cd916 --- /dev/null +++ b/assets/css/procorewp.css @@ -0,0 +1,229 @@ +/*! + * ProcoreWP front-end styles. + * + * Do not edit this file: it is replaced on every plugin update. To restyle the + * output, either add rules under Procore -> Display -> Custom CSS, or copy a + * template from the plugin's templates/ directory into yourtheme/procorewp/. + */ + +.procorewp { + margin: 0 0 1.5rem; +} + +.procorewp-title { + margin: 0 0 0.75rem; + font-size: 1.125rem; + line-height: 1.3; +} + +/* Tables --------------------------------------------------------------- */ + +.procorewp-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.procorewp-table { + width: 100%; + border-collapse: collapse; + font-size: 0.9375rem; +} + +.procorewp-table th, +.procorewp-table td { + padding: 0.625rem 0.75rem; + text-align: left; + vertical-align: top; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); +} + +.procorewp-table thead th { + font-weight: 600; + white-space: nowrap; + border-bottom-width: 2px; +} + +.procorewp-table tbody tr:last-child td { + border-bottom: 0; +} + +/* Detail lists --------------------------------------------------------- */ + +.procorewp-details { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); + gap: 0.75rem 1.5rem; + margin: 0; +} + +.procorewp-detail dt { + margin: 0 0 0.125rem; + font-size: 0.8125rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.7; +} + +.procorewp-detail dd { + margin: 0; +} + +/* Single fields -------------------------------------------------------- */ + +.procorewp-field { + display: inline-flex; + gap: 0.375rem; + align-items: baseline; + margin: 0 0 0.5rem; +} + +.procorewp-field__label { + font-weight: 600; +} + +.procorewp-field__label::after { + content: ":"; +} + +/* Status badges -------------------------------------------------------- */ + +.procorewp-status { + display: inline-block; + padding: 0.125rem 0.5rem; + font-size: 0.8125rem; + line-height: 1.5; + border-radius: 999px; + background: rgba(0, 0, 0, 0.07); +} + +.procorewp-status--active { + background: rgba(22, 128, 70, 0.12); + color: #0f5132; +} + +.procorewp-status--inactive { + background: rgba(0, 0, 0, 0.07); + opacity: 0.8; +} + +/* Images --------------------------------------------------------------- */ + +.procorewp-image { + margin: 0 0 1.5rem; +} + +.procorewp-image img { + max-width: 100%; + height: auto; + display: block; +} + +/* Locations ------------------------------------------------------------ */ + +.procorewp-map__list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr)); + gap: 1rem; +} + +.procorewp-map__item { + padding: 0.875rem 1rem; + border: 1px solid rgba(0, 0, 0, 0.12); + border-radius: 0.375rem; +} + +.procorewp-map__name { + display: block; + font-weight: 600; +} + +.procorewp-map__location { + display: block; + font-size: 0.875rem; + opacity: 0.75; +} + +.procorewp-map__link { + display: inline-block; + margin-top: 0.375rem; + font-size: 0.875rem; +} + +/* Notices -------------------------------------------------------------- */ + +.procorewp-notice { + padding: 0.75rem 1rem; + margin: 0 0 1.5rem; + border-left: 3px solid rgba(0, 0, 0, 0.2); + background: rgba(0, 0, 0, 0.03); + font-size: 0.9375rem; +} + +.procorewp-notice--error { + border-left-color: #b32d2e; + background: rgba(179, 45, 46, 0.06); +} + +.procorewp-notice--empty { + font-style: italic; + opacity: 0.85; +} + +/* Small screens -------------------------------------------------------- */ + +@media (max-width: 600px) { + .procorewp-table, + .procorewp-table thead, + .procorewp-table tbody, + .procorewp-table tr, + .procorewp-table td { + display: block; + width: 100%; + } + + .procorewp-table thead { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + clip-path: inset(50%); + white-space: nowrap; + } + + .procorewp-table tr { + padding: 0.5rem 0; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + } + + .procorewp-table td { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.25rem 0; + border: 0; + } + + .procorewp-table td::before { + content: attr(data-label); + font-weight: 600; + opacity: 0.7; + } +} + +@media (prefers-color-scheme: dark) { + .procorewp-table th, + .procorewp-table td, + .procorewp-map__item { + border-color: rgba(255, 255, 255, 0.15); + } + + .procorewp-status, + .procorewp-notice { + background: rgba(255, 255, 255, 0.07); + } +} diff --git a/src/Admin/Settings.php b/src/Admin/Settings.php new file mode 100644 index 0000000..ccf5976 --- /dev/null +++ b/src/Admin/Settings.php @@ -0,0 +1,309 @@ +|null + */ + private static $cache = null; + + /** + * Default values for every setting. + * + * @return array Defaults. + */ + public static function defaults(): array { + return array( + 'auth_mode' => 'client_credentials', + 'environment' => 'production', + 'custom_login_url' => '', + 'custom_api_url' => '', + 'client_id' => '', + 'client_secret' => '', + 'default_company_id' => 0, + 'default_project_id' => 0, + + 'enable_cache' => true, + 'cache_floor' => 300, + 'cache_multiplier' => 100, + 'per_page' => 100, + 'request_timeout' => 15, + + 'suppress_emails' => true, + 'date_format' => '', + 'currency_symbol' => '$', + 'custom_css' => '', + 'load_styles' => true, + + 'enable_rest' => false, + 'rest_access' => 'logged_in', + 'enable_blocks' => true, + 'enable_logging' => false, + 'keep_data' => false, + ); + } + + /** + * All settings, with defaults applied. + * + * @return array Settings. + */ + public static function all(): array { + if ( null !== self::$cache ) { + return self::$cache; + } + + $stored = get_option( self::OPTION, array() ); + + self::$cache = wp_parse_args( is_array( $stored ) ? $stored : array(), self::defaults() ); + + return self::$cache; + } + + /** + * Read a single setting. + * + * @param string $key Setting key. + * @param mixed $default_value Value returned when the key is unknown. + * @return mixed Setting value. + */ + public static function get( string $key, $default_value = null ) { + $all = self::all(); + + return array_key_exists( $key, $all ) ? $all[ $key ] : $default_value; + } + + /** + * Write a single setting. + * + * @param string $key Setting key. + * @param mixed $value New value. + * @return void + */ + public static function set( string $key, $value ): void { + $all = self::all(); + $all[ $key ] = $value; + + update_option( self::OPTION, $all, false ); + self::flush(); + } + + /** + * Discard the memoised settings. + * + * @return void + */ + public static function flush(): void { + self::$cache = null; + } + + /** + * The effective Procore client ID. + * + * @return string Client ID. + */ + public static function client_id(): string { + if ( defined( 'PROCOREWP_CLIENT_ID' ) && '' !== (string) constant( 'PROCOREWP_CLIENT_ID' ) ) { + return (string) constant( 'PROCOREWP_CLIENT_ID' ); + } + + return (string) self::get( 'client_id', '' ); + } + + /** + * The effective Procore client secret, decrypted. + * + * @return string Client secret. + */ + public static function client_secret(): string { + if ( defined( 'PROCOREWP_CLIENT_SECRET' ) && '' !== (string) constant( 'PROCOREWP_CLIENT_SECRET' ) ) { + return (string) constant( 'PROCOREWP_CLIENT_SECRET' ); + } + + return Encryption::decrypt( (string) self::get( 'client_secret', '' ) ); + } + + /** + * Whether credentials come from `wp-config.php` constants. + * + * @return bool True when constants are in use. + */ + public static function credentials_are_constants(): bool { + return defined( 'PROCOREWP_CLIENT_SECRET' ) && '' !== (string) constant( 'PROCOREWP_CLIENT_SECRET' ); + } + + /** + * The default Procore company identifier. + * + * @return int Company ID, or zero when unset. + */ + public static function default_company_id(): int { + if ( defined( 'PROCOREWP_COMPANY_ID' ) ) { + return absint( constant( 'PROCOREWP_COMPANY_ID' ) ); + } + + return absint( self::get( 'default_company_id', 0 ) ); + } + + /** + * The default Procore project identifier. + * + * @return int Project ID, or zero when unset. + */ + public static function default_project_id(): int { + return absint( self::get( 'default_project_id', 0 ) ); + } + + /** + * Sanitize the settings array submitted from the options form. + * + * Registered as the `sanitize_callback` for `register_setting()`, which + * ProcoreWP 1.x omitted entirely. + * + * @param mixed $input Raw submitted value. + * @return array Sanitized settings. + */ + public static function sanitize( $input ): array { + $existing = self::all(); + $input = is_array( $input ) ? $input : array(); + $clean = $existing; + + $clean['auth_mode'] = in_array( $input['auth_mode'] ?? '', array( 'client_credentials', 'authorization_code' ), true ) + ? $input['auth_mode'] + : 'client_credentials'; + + $clean['environment'] = array_key_exists( $input['environment'] ?? '', Environment::choices() ) + ? $input['environment'] + : 'production'; + + $clean['custom_login_url'] = esc_url_raw( trim( (string) ( $input['custom_login_url'] ?? '' ) ), array( 'https' ) ); + $clean['custom_api_url'] = esc_url_raw( trim( (string) ( $input['custom_api_url'] ?? '' ) ), array( 'https' ) ); + + $clean['client_id'] = sanitize_text_field( (string) ( $input['client_id'] ?? '' ) ); + + // An empty secret field means "leave the stored secret alone"; the form + // renders a masked placeholder rather than the real value. + $submitted_secret = trim( (string) ( $input['client_secret'] ?? '' ) ); + + if ( '' !== $submitted_secret ) { + $clean['client_secret'] = Encryption::encrypt( $submitted_secret ); + } + + if ( ! empty( $input['clear_client_secret'] ) ) { + $clean['client_secret'] = ''; + } + + $clean['default_company_id'] = absint( $input['default_company_id'] ?? 0 ); + $clean['default_project_id'] = absint( $input['default_project_id'] ?? 0 ); + + $clean['enable_cache'] = ! empty( $input['enable_cache'] ); + $clean['cache_floor'] = max( 30, min( DAY_IN_SECONDS, absint( $input['cache_floor'] ?? 300 ) ) ); + $clean['cache_multiplier'] = max( 10, min( 1000, absint( $input['cache_multiplier'] ?? 100 ) ) ); + $clean['per_page'] = max( 1, min( 2000, absint( $input['per_page'] ?? 100 ) ) ); + $clean['request_timeout'] = max( 5, min( 60, absint( $input['request_timeout'] ?? 15 ) ) ); + + $clean['suppress_emails'] = ! empty( $input['suppress_emails'] ); + $clean['date_format'] = sanitize_text_field( (string) ( $input['date_format'] ?? '' ) ); + $clean['currency_symbol'] = sanitize_text_field( (string) ( $input['currency_symbol'] ?? '$' ) ); + $clean['custom_css'] = wp_strip_all_tags( (string) ( $input['custom_css'] ?? '' ) ); + $clean['load_styles'] = ! empty( $input['load_styles'] ); + + $clean['enable_rest'] = ! empty( $input['enable_rest'] ); + $clean['rest_access'] = in_array( $input['rest_access'] ?? '', array( 'public', 'logged_in', 'editor' ), true ) + ? $input['rest_access'] + : 'logged_in'; + $clean['enable_blocks'] = ! empty( $input['enable_blocks'] ); + $clean['enable_logging'] = ! empty( $input['enable_logging'] ); + $clean['keep_data'] = ! empty( $input['keep_data'] ); + + self::flush(); + + /** + * Filters the sanitized settings before they are stored. + * + * @since 2.0.0 + * + * @param array $clean Sanitized settings. + * @param array $input Raw submitted values. + */ + return (array) apply_filters( 'procorewp_sanitize_settings', $clean, $input ); + } + + /** + * Import settings saved by ProcoreWP 1.x. + * + * The 1.x option stored the secret in plain text and held a token obtained + * from the wrong host, so the secret is re-encrypted and the token dropped. + * + * @return bool True when a legacy option was migrated. + */ + public static function migrate_legacy(): bool { + $legacy = get_option( self::LEGACY_OPTION ); + + if ( ! is_array( $legacy ) || empty( $legacy ) ) { + return false; + } + + $current = self::all(); + + if ( ! empty( $legacy['client_id'] ) && '' === (string) $current['client_id'] ) { + $current['client_id'] = sanitize_text_field( (string) $legacy['client_id'] ); + } + + if ( ! empty( $legacy['client_secret'] ) && '' === (string) $current['client_secret'] ) { + $current['client_secret'] = Encryption::encrypt( (string) $legacy['client_secret'] ); + } + + if ( ! empty( $legacy['default_company_id'] ) && 0 === (int) $current['default_company_id'] ) { + $current['default_company_id'] = absint( $legacy['default_company_id'] ); + } + + update_option( self::OPTION, $current, false ); + update_option( 'procorewp_migrated_from', '1.x', false ); + delete_option( self::LEGACY_OPTION ); + + self::flush(); + + return true; + } +} diff --git a/src/Frontend/Assets.php b/src/Frontend/Assets.php new file mode 100644 index 0000000..4126f12 --- /dev/null +++ b/src/Frontend/Assets.php @@ -0,0 +1,135 @@ +queried_content_uses_plugin() ) { + wp_enqueue_style( self::HANDLE ); + } + } + + /** + * Enqueue the stylesheet late when output appeared after the head was sent. + * + * @return void + */ + public function late_enqueue(): void { + if ( ! self::$needed || ! Settings::get( 'load_styles', true ) ) { + return; + } + + if ( wp_style_is( self::HANDLE, 'enqueued' ) ) { + return; + } + + wp_enqueue_style( self::HANDLE ); + } + + /** + * Whether any queried post contains a ProcoreWP shortcode or block. + * + * @return bool True when plugin output is expected. + */ + private function queried_content_uses_plugin(): bool { + global $wp_query; + + if ( ! isset( $wp_query->posts ) || ! is_array( $wp_query->posts ) ) { + return false; + } + + $tags = array_keys( Registrar::definitions() ); + + foreach ( $wp_query->posts as $post ) { + if ( ! isset( $post->post_content ) || ! is_string( $post->post_content ) ) { + continue; + } + + if ( false !== strpos( $post->post_content, 'wp:procorewp/' ) ) { + return true; + } + + foreach ( $tags as $tag ) { + if ( has_shortcode( $post->post_content, $tag ) ) { + return true; + } + } + } + + return false; + } +} diff --git a/src/Frontend/Renderer.php b/src/Frontend/Renderer.php new file mode 100644 index 0000000..1344b88 --- /dev/null +++ b/src/Frontend/Renderer.php @@ -0,0 +1,120 @@ + $data Variables exposed to the template as `$data`. + * @return string Rendered markup. + */ + public static function render( string $template, array $data = array() ): string { + $path = self::locate( $template ); + + if ( '' === $path ) { + return ''; + } + + /** + * Filters the data passed to a ProcoreWP template. + * + * @since 2.0.0 + * + * @param array $data Template data. + * @param string $template Template name. + */ + $data = (array) apply_filters( 'procorewp_template_data', $data, $template ); + + ob_start(); + + // The template reads from $data. + include $path; + + return (string) ob_get_clean(); + } + + /** + * Resolve a template name to a file path. + * + * Child theme, then parent theme, then the plugin's own defaults. + * + * @param string $template Template name without the `.php` extension. + * @return string Absolute path, or an empty string when no template exists. + */ + public static function locate( string $template ): string { + $template = str_replace( array( '..', "\0" ), '', $template ); + $template = trim( preg_replace( '/[^a-z0-9_\-\/]/i', '', $template ) ?? '', '/' ); + + if ( '' === $template ) { + return ''; + } + + $file = $template . '.php'; + + $candidates = array( + trailingslashit( get_stylesheet_directory() ) . self::THEME_DIR . '/' . $file, + trailingslashit( get_template_directory() ) . self::THEME_DIR . '/' . $file, + PROCOREWP_PATH . 'templates/' . $file, + ); + + /** + * Filters the candidate paths considered when locating a template. + * + * @since 2.0.0 + * + * @param array $candidates Absolute candidate paths, in priority order. + * @param string $template Template name. + */ + $candidates = (array) apply_filters( 'procorewp_template_candidates', $candidates, $template ); + + foreach ( $candidates as $candidate ) { + if ( is_readable( $candidate ) ) { + return (string) $candidate; + } + } + + return ''; + } + + /** + * Render a user-facing notice in place of missing or failed content. + * + * The underlying API message is deliberately withheld from the front end: + * Procore errors routinely name accounts, projects and permissions. + * + * @param string $message Public message. + * @param string $type Notice type, used as a modifier class. + * @return string Rendered markup. + */ + public static function notice( string $message, string $type = 'info' ): string { + return sprintf( + '
%2$s
', + esc_attr( sanitize_html_class( $type ) ), + esc_html( $message ) + ); + } +} diff --git a/src/Frontend/Shortcodes/AbstractShortcode.php b/src/Frontend/Shortcodes/AbstractShortcode.php new file mode 100644 index 0000000..47a1cd9 --- /dev/null +++ b/src/Frontend/Shortcodes/AbstractShortcode.php @@ -0,0 +1,394 @@ +> + */ + protected $columns = array(); + + /** + * Additional shortcode-specific attribute defaults. + * + * @var array + */ + protected $extra_atts = array(); + + /** + * Heading rendered above the output when `title` is not overridden. + * + * @var string + */ + protected $default_title = ''; + + /** + * Construct the shortcode from a registry definition. + * + * @param array $definition Shortcode definition. + */ + public function __construct( array $definition ) { + $this->tag = (string) ( $definition['tag'] ?? '' ); + $this->endpoint = (string) ( $definition['endpoint'] ?? '' ); + $this->template = (string) ( $definition['template'] ?? 'collection' ); + $this->columns = (array) ( $definition['columns'] ?? array() ); + $this->extra_atts = (array) ( $definition['atts'] ?? array() ); + $this->default_title = (string) ( $definition['title'] ?? '' ); + } + + /** + * The shortcode tag. + * + * @return string Tag. + */ + public function tag(): string { + return $this->tag; + } + + /** + * Render the shortcode. + * + * @param array|string $atts Raw shortcode attributes. + * @param string|null $content Enclosed content, unused. + * @return string Rendered markup. + */ + public function render( $atts, ?string $content = null ): string { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Signature fixed by add_shortcode(). + $atts = shortcode_atts( $this->defaults(), (array) $atts, $this->tag ); + $atts = $this->sanitize_atts( $atts ); + + Assets::mark_needed(); + + $result = $this->resolve( $atts ); + + if ( is_wp_error( $result ) ) { + return $this->error( $result ); + } + + return $this->output( $result, $atts ); + } + + /** + * Attribute defaults, merging the common vocabulary with per-shortcode extras. + * + * @return array Defaults. + */ + public function defaults(): array { + return array_merge( + array( + 'company_id' => '', + 'project_id' => '', + 'id' => '', + 'limit' => 0, + 'page' => 1, + 'orderby' => '', + 'order' => 'asc', + 'columns' => '', + 'fields' => '', + 'template' => '', + 'class' => '', + 'title' => '', + 'cache' => '', + 'empty_text' => '', + 'show_email' => 'false', + 'all' => 'false', + ), + $this->extra_atts + ); + } + + /** + * Sanitize every attribute before it is used. + * + * @param array $atts Raw attributes. + * @return array Sanitized attributes. + */ + protected function sanitize_atts( array $atts ): array { + $clean = array(); + + foreach ( $atts as $key => $value ) { + switch ( $key ) { + case 'company_id': + case 'project_id': + case 'id': + case 'limit': + case 'page': + case 'cache': + case 'width': + case 'height': + case 'max_pages': + $clean[ $key ] = '' === $value ? '' : absint( $value ); + break; + + case 'order': + $clean[ $key ] = 'desc' === strtolower( (string) $value ) ? 'desc' : 'asc'; + break; + + case 'orderby': + case 'endpoint': + $clean[ $key ] = preg_replace( '/[^a-z0-9_.\-]/i', '', (string) $value ) ?? ''; + break; + + case 'columns': + case 'fields': + $clean[ $key ] = preg_replace( '/[^a-z0-9_.,\-]/i', '', (string) $value ) ?? ''; + break; + + case 'template': + $clean[ $key ] = preg_replace( '/[^a-z0-9_\-]/i', '', (string) $value ) ?? ''; + break; + + case 'class': + $clean[ $key ] = Format::classes( '', (string) $value ); + break; + + case 'show_email': + case 'all': + case 'show_details': + case 'active_only': + $clean[ $key ] = in_array( strtolower( (string) $value ), array( 'true', '1', 'yes', 'on' ), true ); + break; + + default: + $clean[ $key ] = sanitize_text_field( (string) $value ); + break; + }//end switch + }//end foreach + + return $clean; + } + + /** + * Retrieve the data this shortcode renders. + * + * @param array $atts Sanitized attributes. + * @return mixed|\WP_Error Response payload, or an error. + */ + protected function resolve( array $atts ) { + if ( '' === $this->endpoint || ! Endpoints::exists( $this->endpoint ) ) { + return new \WP_Error( 'procorewp_unknown_endpoint', __( 'This shortcode is not bound to a known Procore endpoint.', 'procorewp' ) ); + } + + return Client::instance()->fetch( $this->endpoint, $this->query_args( $atts ), $this->fetch_options( $atts ) ); + } + + /** + * Query parameters sent with the request. + * + * @param array $atts Sanitized attributes. + * @return array Query parameters. + */ + protected function query_args( array $atts ): array { + $args = array(); + + if ( ! empty( $atts['page'] ) ) { + $args['page'] = (int) $atts['page']; + } + + return $args; + } + + /** + * Client behaviour options derived from the attributes. + * + * @param array $atts Sanitized attributes. + * @return array Options. + */ + protected function fetch_options( array $atts ): array { + $options = array( + 'company_id' => $this->company_id( $atts ), + 'project_id' => $this->project_id( $atts ), + 'all' => ! empty( $atts['all'] ), + ); + + if ( '' !== $atts['cache'] && $atts['cache'] > 0 ) { + $options['ttl'] = Cache::clamp_ttl( (int) $atts['cache'] ); + } + + if ( ! empty( $atts['limit'] ) ) { + $options['per_page'] = min( 2000, max( 1, (int) $atts['limit'] ) ); + } + + return $options; + } + + /** + * Resolve the company identifier for this render. + * + * @param array $atts Sanitized attributes. + * @return int Company identifier. + */ + protected function company_id( array $atts ): int { + $explicit = absint( $atts['company_id'] ); + + return $explicit > 0 ? $explicit : Settings::default_company_id(); + } + + /** + * Resolve the project identifier for this render. + * + * The legacy `id` attribute is accepted as an alias so pages written for + * ProcoreWP 1.x continue to work unchanged. + * + * @param array $atts Sanitized attributes. + * @return int Project identifier. + */ + protected function project_id( array $atts ): int { + $explicit = absint( $atts['project_id'] ); + + if ( $explicit <= 0 ) { + $explicit = absint( $atts['id'] ); + } + + return $explicit > 0 ? $explicit : Settings::default_project_id(); + } + + /** + * Column definitions, honouring a `columns` attribute override. + * + * @param array $atts Sanitized attributes. + * @return array> Column definitions. + */ + protected function resolve_columns( array $atts ): array { + $list = '' !== (string) $atts['columns'] ? (string) $atts['columns'] : (string) $atts['fields']; + $requested = array_filter( array_map( 'trim', explode( ',', $list ) ) ); + + if ( empty( $requested ) ) { + return $this->columns; + } + + $indexed = array(); + + foreach ( $this->columns as $column ) { + $indexed[ $column['key'] ] = $column; + } + + $resolved = array(); + + foreach ( $requested as $key ) { + $resolved[] = $indexed[ $key ] ?? array( + 'key' => $key, + 'label' => Format::label( $key ), + 'format' => 'text', + ); + } + + return $resolved; + } + + /** + * Build the rendered output. + * + * @param mixed $data Response payload. + * @param array $atts Sanitized attributes. + * @return string Rendered markup. + */ + abstract protected function output( $data, array $atts ): string; + + /** + * The template name for this render. + * + * @param array $atts Sanitized attributes. + * @return string Template name. + */ + protected function template( array $atts ): string { + return '' !== $atts['template'] ? (string) $atts['template'] : $this->template; + } + + /** + * Convert an API failure into safe front-end output. + * + * Administrators see the underlying message; everyone else sees a neutral + * notice, because Procore errors routinely name accounts and permissions. + * + * @param \WP_Error $error Failure. + * @return string Rendered markup. + */ + protected function error( \WP_Error $error ): string { + if ( current_user_can( 'manage_options' ) ) { + return Renderer::notice( + sprintf( + /* translators: %s: error message from the Procore API. */ + __( 'ProcoreWP (visible to administrators only): %s', 'procorewp' ), + $error->get_error_message() + ), + 'error' + ); + } + + return Renderer::notice( __( 'Project information is temporarily unavailable.', 'procorewp' ), 'error' ); + } + + /** + * The heading rendered above the output. + * + * @param array $atts Sanitized attributes. + * @return string Heading text, or an empty string to omit it. + */ + protected function title( array $atts ): string { + if ( isset( $atts['title'] ) && '' !== $atts['title'] ) { + return '-' === $atts['title'] ? '' : (string) $atts['title']; + } + + return $this->default_title; + } + + /** + * The message shown when the endpoint returns no records. + * + * @param array $atts Sanitized attributes. + * @return string Message. + */ + protected function empty_text( array $atts ): string { + return '' !== $atts['empty_text'] + ? (string) $atts['empty_text'] + : __( 'No records were found.', 'procorewp' ); + } +} diff --git a/src/Frontend/Shortcodes/CollectionShortcode.php b/src/Frontend/Shortcodes/CollectionShortcode.php new file mode 100644 index 0000000..2735849 --- /dev/null +++ b/src/Frontend/Shortcodes/CollectionShortcode.php @@ -0,0 +1,197 @@ + $atts Sanitized attributes. + * @return string Rendered markup. + */ + protected function output( $data, array $atts ): string { + $rows = $this->extract_rows( $data ); + $rows = $this->filter_rows( $rows, $atts ); + + $orderby = $this->orderby( $atts ); + + if ( '' !== $orderby ) { + $rows = Arr::sort_by( $rows, $orderby, $this->order( $atts ) ); + } + + $limit = absint( $atts['limit'] ); + + if ( $limit > 0 && count( $rows ) > $limit ) { + $rows = array_slice( $rows, 0, $limit ); + } + + if ( empty( $rows ) ) { + return Renderer::notice( $this->empty_text( $atts ), 'empty' ); + } + + return Renderer::render( + $this->template( $atts ), + array( + 'rows' => $rows, + 'columns' => $this->resolve_columns( $atts ), + 'title' => $this->title( $atts ), + 'class' => Format::classes( 'procorewp procorewp-collection procorewp-' . str_replace( '_', '-', $this->endpoint ), (string) $atts['class'] ), + 'show_email' => ! empty( $atts['show_email'] ), + 'atts' => $atts, + 'tag' => $this->tag, + ) + ); + } + + /** + * The field to sort on, honouring the ProcoreWP 1.x `sort_by` alias. + * + * @param array $atts Sanitized attributes. + * @return string Field path, or an empty string for no sorting. + */ + protected function orderby( array $atts ): string { + if ( '' !== (string) $atts['orderby'] ) { + return (string) $atts['orderby']; + } + + return (string) ( $atts['sort_by'] ?? '' ); + } + + /** + * The sort direction, honouring the ProcoreWP 1.x `sort_order` alias. + * + * @param array $atts Sanitized attributes. + * @return string Either `asc` or `desc`. + */ + protected function order( array $atts ): string { + $legacy = strtolower( (string) ( $atts['sort_order'] ?? '' ) ); + + if ( 'desc' === $legacy ) { + return 'desc'; + } + + if ( 'asc' === $legacy ) { + return 'asc'; + } + + return (string) $atts['order']; + } + + /** + * Column definitions, honouring the ProcoreWP 1.x `show_details` attribute. + * + * In 1.x, `show_details="false"` reduced the project list to ID and name. + * An explicit `false` is still respected so existing pages look the same; + * omitting it now shows the full column set, which is the more useful + * default for a new page. + * + * @param array $atts Sanitized attributes. + * @return array> Column definitions. + */ + protected function resolve_columns( array $atts ): array { + $columns = parent::resolve_columns( $atts ); + + if ( ! array_key_exists( 'show_details', $atts ) || $atts['show_details'] ) { + return $columns; + } + + if ( '' !== (string) $atts['columns'] || '' !== (string) $atts['fields'] ) { + return $columns; + } + + return array_values( + array_filter( + $columns, + static function ( array $column ): bool { + return in_array( (string) $column['key'], array( 'id', 'name' ), true ); + } + ) + ); + } + + /** + * Normalise a response body into a list of records. + * + * Some Procore resources return a bare array, others wrap the list in a + * named key such as `data` or the resource name. + * + * @param mixed $data Response payload. + * @return array Records. + */ + protected function extract_rows( $data ): array { + if ( Arr::is_list( $data ) ) { + return (array) $data; + } + + if ( ! is_array( $data ) ) { + return array(); + } + + foreach ( array( 'data', 'items', $this->endpoint ) as $key ) { + if ( isset( $data[ $key ] ) && Arr::is_list( $data[ $key ] ) ) { + return (array) $data[ $key ]; + } + } + + // A single record returned where a list was expected. + return array( $data ); + } + + /** + * Apply shortcode-level row filtering. + * + * @param array $rows Records. + * @param array $atts Sanitized attributes. + * @return array Filtered records. + */ + protected function filter_rows( array $rows, array $atts ): array { + if ( array_key_exists( 'active_only', $atts ) && $atts['active_only'] ) { + $rows = array_values( + array_filter( + $rows, + static function ( $row ): bool { + $active = Arr::get( $row, 'active' ); + + return null === $active ? true : (bool) $active; + } + ) + ); + } + + if ( ! empty( $atts['status'] ) ) { + $wanted = strtolower( (string) $atts['status'] ); + + $rows = array_values( + array_filter( + $rows, + static function ( $row ) use ( $wanted ): bool { + return strtolower( Arr::str( $row, 'status' ) ) === $wanted; + } + ) + ); + } + + return $rows; + } +} diff --git a/src/Frontend/Shortcodes/DataShortcode.php b/src/Frontend/Shortcodes/DataShortcode.php new file mode 100644 index 0000000..346e236 --- /dev/null +++ b/src/Frontend/Shortcodes/DataShortcode.php @@ -0,0 +1,92 @@ + Defaults. + */ + public function defaults(): array { + return array_merge( + parent::defaults(), + array( + 'endpoint' => '', + 'status' => '', + ) + ); + } + + /** + * Retrieve the data this shortcode renders. + * + * @param array $atts Sanitized attributes. + * @return mixed|\WP_Error Response payload, or an error. + */ + protected function resolve( array $atts ) { + $slug = (string) $atts['endpoint']; + + if ( '' === $slug ) { + return new \WP_Error( + 'procorewp_missing_endpoint', + sprintf( + /* translators: %s: comma-separated list of endpoint slugs. */ + __( 'The endpoint attribute is required. Available endpoints: %s', 'procorewp' ), + implode( ', ', array_keys( Endpoints::public_endpoints() ) ) + ) + ); + } + + if ( ! array_key_exists( $slug, Endpoints::public_endpoints() ) ) { + return new \WP_Error( + 'procorewp_endpoint_not_public', + sprintf( + /* translators: 1: requested endpoint slug, 2: comma-separated list of endpoint slugs. */ + __( 'The endpoint "%1$s" is not available to shortcodes. Available endpoints: %2$s', 'procorewp' ), + $slug, + implode( ', ', array_keys( Endpoints::public_endpoints() ) ) + ) + ); + } + + $this->endpoint = $slug; + + $definition = Endpoints::get( $slug ); + + if ( empty( $this->columns ) && null !== $definition ) { + $this->columns = array_map( + static function ( string $field ): array { + return array( + 'key' => $field, + 'label' => Format::label( $field ), + 'format' => 'text', + ); + }, + (array) $definition['fields'] + ); + } + + return parent::resolve( $atts ); + } +} diff --git a/src/Frontend/Shortcodes/FieldShortcode.php b/src/Frontend/Shortcodes/FieldShortcode.php new file mode 100644 index 0000000..b0746e7 --- /dev/null +++ b/src/Frontend/Shortcodes/FieldShortcode.php @@ -0,0 +1,183 @@ + Allow-listed field paths. + */ + public static function allowed_fields(): array { + $fields = array( + 'name', + 'display_name', + 'project_number', + 'description', + 'address', + 'city', + 'state_code', + 'zip', + 'country_code', + 'county', + 'latitude', + 'longitude', + 'phone', + 'time_zone', + 'stage', + 'active', + 'start_date', + 'completion_date', + 'actual_start_date', + 'projected_finish_date', + 'total_value', + 'estimated_value', + 'square_feet', + 'store_number', + 'department', + 'project_type.name', + 'office.name', + 'owners_project_id', + 'created_at', + 'updated_at', + ); + + /** + * Filters the fields `[procore_project_data]` may display. + * + * @since 2.0.0 + * + * @param array $fields Allow-listed field paths. + */ + return array_values( array_unique( (array) apply_filters( 'procorewp_allowed_project_fields', $fields ) ) ); + } + + /** + * Attribute defaults. + * + * @return array Defaults. + */ + public function defaults(): array { + return array_merge( + parent::defaults(), + array( + 'field' => '', + 'label' => '', + 'format' => '', + ) + ); + } + + /** + * Build the rendered output. + * + * @param mixed $data Response payload. + * @param array $atts Sanitized attributes. + * @return string Rendered markup. + */ + protected function output( $data, array $atts ): string { + $field = (string) $atts['field']; + + if ( '' === $field ) { + return $this->error( new \WP_Error( 'procorewp_missing_field', __( 'The field attribute is required.', 'procorewp' ) ) ); + } + + if ( ! in_array( $field, self::allowed_fields(), true ) ) { + return $this->error( + new \WP_Error( + 'procorewp_field_not_allowed', + sprintf( + /* translators: %s: requested field name. */ + __( 'The field "%s" is not available for display. Add it with the procorewp_allowed_project_fields filter.', 'procorewp' ), + $field + ) + ) + ); + } + + $raw = Arr::get( $data, $field ); + + if ( null === $raw || '' === $raw ) { + return Renderer::notice( $this->empty_text( $atts ), 'empty' ); + } + + $value = $this->format_value( $raw, (string) $atts['format'], $field ); + + return Renderer::render( + $this->template( $atts ), + array( + 'label' => '' !== $atts['label'] ? (string) $atts['label'] : Format::label( $field ), + 'value' => $value, + 'field' => $field, + 'class' => Format::classes( 'procorewp procorewp-field', (string) $atts['class'] ), + 'atts' => $atts, + 'tag' => $this->tag, + ) + ); + } + + /** + * Format a raw value for display. + * + * @param mixed $raw Raw value. + * @param string $format Explicit format hint from the shortcode. + * @param string $field Field name, used to infer a format. + * @return string Display value. + */ + private function format_value( $raw, string $format, string $field ): string { + if ( '' === $format ) { + if ( preg_match( '/(_date|_at)$/', $field ) ) { + $format = 'date'; + } elseif ( preg_match( '/(value|budget|total|amount)$/', $field ) ) { + $format = 'currency'; + } + } + + switch ( $format ) { + case 'date': + return Format::date( $raw ); + + case 'currency': + return Format::currency( $raw ); + + case 'number': + return is_numeric( $raw ) ? number_format_i18n( (float) $raw ) : Arr::stringify( $raw ); + + default: + return Arr::stringify( $raw ); + } + } + + /** + * The empty-state message for a missing field value. + * + * @param array $atts Sanitized attributes. + * @return string Message. + */ + protected function empty_text( array $atts ): string { + return '' !== $atts['empty_text'] + ? (string) $atts['empty_text'] + : __( 'Not available.', 'procorewp' ); + } +} diff --git a/src/Frontend/Shortcodes/ImageShortcode.php b/src/Frontend/Shortcodes/ImageShortcode.php new file mode 100644 index 0000000..46e2d9c --- /dev/null +++ b/src/Frontend/Shortcodes/ImageShortcode.php @@ -0,0 +1,106 @@ + + */ + private const IMAGE_KEYS = array( 'logo_url', 'photo_url', 'image_url', 'thumbnail_url', 'logo.url' ); + + /** + * Attribute defaults. + * + * @return array Defaults. + */ + public function defaults(): array { + return array_merge( + parent::defaults(), + array( + 'width' => 300, + 'height' => '', + 'alt' => '', + 'lazy' => 'true', + ) + ); + } + + /** + * Build the rendered output. + * + * @param mixed $data Response payload. + * @param array $atts Sanitized attributes. + * @return string Rendered markup. + */ + protected function output( $data, array $atts ): string { + $url = ''; + + foreach ( self::IMAGE_KEYS as $key ) { + $candidate = Arr::str( $data, $key ); + + if ( '' !== $candidate ) { + $url = $candidate; + break; + } + } + + $url = esc_url_raw( $url, array( 'https' ) ); + + if ( '' === $url ) { + return Renderer::notice( $this->empty_text( $atts ), 'empty' ); + } + + $alt = '' !== $atts['alt'] + ? (string) $atts['alt'] + : sprintf( + /* translators: %s: project name. */ + __( '%s project image', 'procorewp' ), + Arr::str( $data, 'name', __( 'Procore', 'procorewp' ) ) + ); + + return Renderer::render( + $this->template( $atts ), + array( + 'url' => $url, + 'alt' => $alt, + 'width' => absint( $atts['width'] ), + 'height' => absint( $atts['height'] ), + 'lazy' => in_array( strtolower( (string) $atts['lazy'] ), array( 'true', '1', 'yes', 'on' ), true ), + 'class' => Format::classes( 'procorewp procorewp-image', (string) $atts['class'] ), + 'atts' => $atts, + 'tag' => $this->tag, + ) + ); + } + + /** + * The empty-state message when no image is available. + * + * @param array $atts Sanitized attributes. + * @return string Message. + */ + protected function empty_text( array $atts ): string { + return '' !== $atts['empty_text'] + ? (string) $atts['empty_text'] + : __( 'No project image is available.', 'procorewp' ); + } +} diff --git a/src/Frontend/Shortcodes/MapShortcode.php b/src/Frontend/Shortcodes/MapShortcode.php new file mode 100644 index 0000000..eaaddcf --- /dev/null +++ b/src/Frontend/Shortcodes/MapShortcode.php @@ -0,0 +1,107 @@ + Defaults. + */ + public function defaults(): array { + return array_merge( + parent::defaults(), + array( + 'active_only' => 'true', + 'link' => 'true', + ) + ); + } + + /** + * Build the rendered output. + * + * @param mixed $data Response payload. + * @param array $atts Sanitized attributes. + * @return string Rendered markup. + */ + protected function output( $data, array $atts ): string { + $rows = $this->filter_rows( $this->extract_rows( $data ), $atts ); + $points = array(); + + foreach ( $rows as $row ) { + $latitude = Arr::get( $row, 'latitude' ); + $longitude = Arr::get( $row, 'longitude' ); + + if ( ! is_numeric( $latitude ) || ! is_numeric( $longitude ) ) { + continue; + } + + $points[] = array( + 'id' => absint( Arr::get( $row, 'id', 0 ) ), + 'name' => Arr::str( $row, 'name' ), + 'location' => Format::location( $row ), + 'latitude' => round( (float) $latitude, 6 ), + 'longitude' => round( (float) $longitude, 6 ), + ); + } + + $limit = absint( $atts['limit'] ); + + if ( $limit > 0 && count( $points ) > $limit ) { + $points = array_slice( $points, 0, $limit ); + } + + if ( empty( $points ) ) { + return Renderer::notice( $this->empty_text( $atts ), 'empty' ); + } + + return Renderer::render( + $this->template( $atts ), + array( + 'points' => $points, + 'title' => $this->title( $atts ), + 'link' => in_array( strtolower( (string) $atts['link'] ), array( 'true', '1', 'yes', 'on' ), true ), + 'class' => Format::classes( 'procorewp procorewp-map', (string) $atts['class'] ), + 'atts' => $atts, + 'tag' => $this->tag, + ) + ); + } + + /** + * The empty-state message when no project has coordinates. + * + * @param array $atts Sanitized attributes. + * @return string Message. + */ + protected function empty_text( array $atts ): string { + return '' !== $atts['empty_text'] + ? (string) $atts['empty_text'] + : __( 'No project locations are available.', 'procorewp' ); + } +} diff --git a/src/Frontend/Shortcodes/RecordShortcode.php b/src/Frontend/Shortcodes/RecordShortcode.php new file mode 100644 index 0000000..0be8b08 --- /dev/null +++ b/src/Frontend/Shortcodes/RecordShortcode.php @@ -0,0 +1,47 @@ + $atts Sanitized attributes. + * @return string Rendered markup. + */ + protected function output( $data, array $atts ): string { + if ( ! is_array( $data ) || empty( $data ) ) { + return Renderer::notice( $this->empty_text( $atts ), 'empty' ); + } + + return Renderer::render( + $this->template( $atts ), + array( + 'record' => $data, + 'columns' => $this->resolve_columns( $atts ), + 'title' => '' !== $this->title( $atts ) ? $this->title( $atts ) : (string) ( $data['name'] ?? '' ), + 'class' => Format::classes( 'procorewp procorewp-record procorewp-' . str_replace( '_', '-', $this->endpoint ), (string) $atts['class'] ), + 'show_email' => ! empty( $atts['show_email'] ), + 'atts' => $atts, + 'tag' => $this->tag, + ) + ); + } +} diff --git a/src/Frontend/Shortcodes/Registrar.php b/src/Frontend/Shortcodes/Registrar.php new file mode 100644 index 0000000..1c69197 --- /dev/null +++ b/src/Frontend/Shortcodes/Registrar.php @@ -0,0 +1,550 @@ +>|null + */ + private static $definitions = null; + + /** + * Register every shortcode with WordPress. + * + * @return void + */ + public function register(): void { + foreach ( self::definitions() as $tag => $definition ) { + $handler = $definition['handler']; + $instance = new $handler( $definition ); + + add_shortcode( $tag, array( $instance, 'render' ) ); + } + } + + /** + * All shortcode definitions, keyed by tag. + * + * @return array> Definitions. + */ + public static function definitions(): array { + if ( null !== self::$definitions ) { + return self::$definitions; + } + + $definitions = array( + + /* ---- Preserved from ProcoreWP 1.x ---- */ + + 'procore_project_list' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'projects', + 'template' => 'collection', + 'title' => __( 'Projects', 'procorewp' ), + 'description' => __( 'A table of Procore projects for a company.', 'procorewp' ), + // show_details, sort_by and sort_order are ProcoreWP 1.x aliases, + // still honoured so existing pages render the same way. + 'atts' => array( + 'active_only' => 'true', + 'show_details' => 'true', + 'sort_by' => '', + 'sort_order' => '', + ), + 'columns' => array( + array( + 'key' => 'id', + 'label' => __( 'ID', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'name', + 'label' => __( 'Project', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'project_number', + 'label' => __( 'Number', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => '__location', + 'label' => __( 'Location', 'procorewp' ), + 'format' => 'location', + ), + array( + 'key' => 'active', + 'label' => __( 'Status', 'procorewp' ), + 'format' => 'status', + ), + ), + ), + 'procore_project' => array( + 'handler' => RecordShortcode::class, + 'endpoint' => 'project', + 'template' => 'record', + 'description' => __( 'Detail panel for a single Procore project.', 'procorewp' ), + 'columns' => array( + array( + 'key' => 'address', + 'label' => __( 'Address', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => '__location', + 'label' => __( 'Location', 'procorewp' ), + 'format' => 'location', + ), + array( + 'key' => 'zip', + 'label' => __( 'Postcode', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'project_number', + 'label' => __( 'Project number', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'stage', + 'label' => __( 'Stage', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'start_date', + 'label' => __( 'Start date', 'procorewp' ), + 'format' => 'date', + ), + array( + 'key' => 'completion_date', + 'label' => __( 'Completion date', 'procorewp' ), + 'format' => 'date', + ), + array( + 'key' => 'active', + 'label' => __( 'Status', 'procorewp' ), + 'format' => 'status', + ), + ), + ), + 'procore_team' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'project_users', + 'template' => 'collection', + 'title' => __( 'Project team', 'procorewp' ), + 'description' => __( 'Team members assigned to a project. Email addresses are hidden unless explicitly enabled.', 'procorewp' ), + 'columns' => array( + array( + 'key' => 'name', + 'label' => __( 'Name', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'job_title', + 'label' => __( 'Role', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'vendor.name', + 'label' => __( 'Company', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'email_address', + 'label' => __( 'Email', 'procorewp' ), + 'format' => 'email', + ), + ), + ), + 'procore_featured_image' => array( + 'handler' => ImageShortcode::class, + 'endpoint' => 'project', + 'template' => 'image', + 'description' => __( 'The logo or featured image for a project.', 'procorewp' ), + ), + 'procore_drawings' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'drawing_areas', + 'template' => 'collection', + 'title' => __( 'Drawings', 'procorewp' ), + 'description' => __( 'Drawing areas published for a project.', 'procorewp' ), + 'columns' => array( + array( + 'key' => 'name', + 'label' => __( 'Name', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'description', + 'label' => __( 'Description', 'procorewp' ), + 'format' => 'text', + ), + ), + ), + 'procore_specifications' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'specification_sections', + 'template' => 'collection', + 'title' => __( 'Specifications', 'procorewp' ), + 'description' => __( 'Specification sections published for a project.', 'procorewp' ), + 'columns' => array( + array( + 'key' => 'number', + 'label' => __( 'Number', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'description', + 'label' => __( 'Description', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'revision', + 'label' => __( 'Revision', 'procorewp' ), + 'format' => 'text', + ), + ), + ), + 'procore_project_data' => array( + 'handler' => FieldShortcode::class, + 'endpoint' => 'project', + 'template' => 'field', + 'description' => __( 'A single allow-listed field from a project record.', 'procorewp' ), + ), + + /* ---- Project management tools ---- */ + + 'procore_rfis' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'rfis', + 'template' => 'collection', + 'title' => __( 'RFIs', 'procorewp' ), + 'description' => __( 'Requests for information raised on a project.', 'procorewp' ), + 'atts' => array( 'status' => '' ), + 'columns' => array( + array( + 'key' => 'number', + 'label' => __( 'Number', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'subject', + 'label' => __( 'Subject', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'due_date', + 'label' => __( 'Due', 'procorewp' ), + 'format' => 'date', + ), + ), + ), + 'procore_submittals' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'submittals', + 'template' => 'collection', + 'title' => __( 'Submittals', 'procorewp' ), + 'description' => __( 'Submittals tracked on a project.', 'procorewp' ), + 'atts' => array( 'status' => '' ), + 'columns' => array( + array( + 'key' => 'number', + 'label' => __( 'Number', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'title', + 'label' => __( 'Title', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'due_date', + 'label' => __( 'Due', 'procorewp' ), + 'format' => 'date', + ), + ), + ), + 'procore_punch_list' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'punch_items', + 'template' => 'collection', + 'title' => __( 'Punch list', 'procorewp' ), + 'description' => __( 'Outstanding punch list items for a project.', 'procorewp' ), + 'atts' => array( 'status' => '' ), + 'columns' => array( + array( + 'key' => 'position', + 'label' => __( 'Item', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'name', + 'label' => __( 'Description', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'due_date', + 'label' => __( 'Due', 'procorewp' ), + 'format' => 'date', + ), + ), + ), + 'procore_observations' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'observations', + 'template' => 'collection', + 'title' => __( 'Observations', 'procorewp' ), + 'description' => __( 'Quality and safety observations recorded on a project.', 'procorewp' ), + 'atts' => array( 'status' => '' ), + 'columns' => array( + array( + 'key' => 'number', + 'label' => __( 'Number', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'name', + 'label' => __( 'Observation', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'due_date', + 'label' => __( 'Due', 'procorewp' ), + 'format' => 'date', + ), + ), + ), + 'procore_daily_logs' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'daily_logs', + 'template' => 'collection', + 'title' => __( 'Daily logs', 'procorewp' ), + 'description' => __( 'Daily construction report logs for a project.', 'procorewp' ), + 'columns' => array( + array( + 'key' => 'date', + 'label' => __( 'Date', 'procorewp' ), + 'format' => 'date', + ), + array( + 'key' => 'comments', + 'label' => __( 'Notes', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procorewp' ), + 'format' => 'text', + ), + ), + ), + 'procore_change_orders' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'change_orders', + 'template' => 'collection', + 'title' => __( 'Change orders', 'procorewp' ), + 'description' => __( 'Change order packages raised on a project.', 'procorewp' ), + 'atts' => array( 'status' => '' ), + 'columns' => array( + array( + 'key' => 'number', + 'label' => __( 'Number', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'title', + 'label' => __( 'Title', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'status', + 'label' => __( 'Status', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'grand_total', + 'label' => __( 'Value', 'procorewp' ), + 'format' => 'currency', + ), + ), + ), + 'procore_milestones' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'milestones', + 'template' => 'collection', + 'title' => __( 'Schedule', 'procorewp' ), + 'description' => __( 'Schedule tasks and milestones for a project.', 'procorewp' ), + 'columns' => array( + array( + 'key' => 'name', + 'label' => __( 'Task', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'start_date', + 'label' => __( 'Start', 'procorewp' ), + 'format' => 'date', + ), + array( + 'key' => 'finish_date', + 'label' => __( 'Finish', 'procorewp' ), + 'format' => 'date', + ), + array( + 'key' => 'percent_complete', + 'label' => __( 'Complete', 'procorewp' ), + 'format' => 'percent', + ), + ), + ), + + /* ---- Company and directory ---- */ + + 'procore_vendors' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'company_vendors', + 'template' => 'collection', + 'title' => __( 'Vendors', 'procorewp' ), + 'description' => __( 'Companies in the Procore company directory.', 'procorewp' ), + 'columns' => array( + array( + 'key' => 'name', + 'label' => __( 'Company', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => '__location', + 'label' => __( 'Location', 'procorewp' ), + 'format' => 'location', + ), + array( + 'key' => 'business_phone', + 'label' => __( 'Phone', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'website', + 'label' => __( 'Website', 'procorewp' ), + 'format' => 'url', + ), + ), + ), + 'procore_offices' => array( + 'handler' => CollectionShortcode::class, + 'endpoint' => 'offices', + 'template' => 'collection', + 'title' => __( 'Offices', 'procorewp' ), + 'description' => __( 'Offices registered against the Procore company.', 'procorewp' ), + 'columns' => array( + array( + 'key' => 'name', + 'label' => __( 'Office', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => 'address', + 'label' => __( 'Address', 'procorewp' ), + 'format' => 'text', + ), + array( + 'key' => '__location', + 'label' => __( 'Location', 'procorewp' ), + 'format' => 'location', + ), + array( + 'key' => 'phone', + 'label' => __( 'Phone', 'procorewp' ), + 'format' => 'text', + ), + ), + ), + 'procore_project_map' => array( + 'handler' => MapShortcode::class, + 'endpoint' => 'projects', + 'template' => 'map', + 'title' => __( 'Project locations', 'procorewp' ), + 'description' => __( 'Projects that have coordinates, as an accessible location list with geo microdata.', 'procorewp' ), + ), + + /* ---- Generic ---- */ + + 'procore_data' => array( + 'handler' => DataShortcode::class, + 'endpoint' => '', + 'template' => 'collection', + 'description' => __( 'Render any endpoint published in the Procore endpoint registry.', 'procorewp' ), + ), + ); + + /** + * Filters the shortcode registry. + * + * @since 2.0.0 + * + * @param array> $definitions Shortcode definitions keyed by tag. + */ + $definitions = (array) apply_filters( 'procorewp_shortcodes', $definitions ); + + foreach ( $definitions as $tag => $definition ) { + $definitions[ $tag ]['tag'] = $tag; + } + + self::$definitions = $definitions; + + return self::$definitions; + } + + /** + * Retrieve a single definition. + * + * @param string $tag Shortcode tag. + * @return array|null Definition, or null when unregistered. + */ + public static function get( string $tag ): ?array { + $definitions = self::definitions(); + + return $definitions[ $tag ] ?? null; + } +} diff --git a/src/Plugin.php b/src/Plugin.php new file mode 100644 index 0000000..6e821f3 --- /dev/null +++ b/src/Plugin.php @@ -0,0 +1,198 @@ +boot(); + } + + return self::$instance; + } + + /** + * Register hooks. + * + * @return void + */ + private function boot(): void { + add_action( 'init', array( $this, 'load_textdomain' ) ); + add_action( 'init', array( $this, 'maybe_upgrade' ) ); + + ( new ShortcodeRegistrar() )->register(); + ( new Assets() )->register(); + + if ( is_admin() ) { + ( new SettingsPage() )->register(); + ( new Ajax() )->register(); + ( new Notices() )->register(); + } + + ( new OAuthController() )->register(); + + if ( Settings::get( 'enable_blocks', true ) ) { + ( new BlockRegistrar() )->register(); + } + + if ( Settings::get( 'enable_rest', false ) ) { + ( new RestController() )->register(); + } + + add_action( self::CRON_HOOK, array( $this, 'warm_cache' ) ); + add_filter( 'plugin_action_links_' . PROCOREWP_BASENAME, array( $this, 'action_links' ) ); + + if ( defined( 'WP_CLI' ) && WP_CLI ) { + Commands::register(); + } + } + + /** + * Load the plugin text domain. + * + * @return void + */ + public function load_textdomain(): void { + load_plugin_textdomain( 'procorewp', false, dirname( PROCOREWP_BASENAME ) . '/languages' ); + } + + /** + * Run upgrade routines when the stored version is behind the running one. + * + * @return void + */ + public function maybe_upgrade(): void { + $installed = (string) get_option( self::VERSION_OPTION, '' ); + + if ( PROCOREWP_VERSION === $installed ) { + return; + } + + if ( '' === $installed ) { + Settings::migrate_legacy(); + } + + if ( '' !== $installed && version_compare( $installed, '2.0.0', '<' ) ) { + Cache::flush(); + } + + update_option( self::VERSION_OPTION, PROCOREWP_VERSION, false ); + } + + /** + * Refresh cached responses for the configured default company. + * + * @return void + */ + public function warm_cache(): void { + $company = Settings::default_company_id(); + + if ( $company <= 0 ) { + return; + } + + Api\Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'bypass_cache' => true, + ) + ); + } + + /** + * Add a Settings link to the plugins list row. + * + * @param array $links Existing action links. + * @return array Modified action links. + */ + public function action_links( array $links ): array { + $settings = sprintf( + '%s', + esc_url( admin_url( 'admin.php?page=procorewp' ) ), + esc_html__( 'Settings', 'procorewp' ) + ); + + array_unshift( $links, $settings ); + + return $links; + } + + /** + * Activation handler. + * + * @return void + */ + public static function activate(): void { + Settings::migrate_legacy(); + + if ( false === get_option( Settings::OPTION ) ) { + add_option( Settings::OPTION, Settings::defaults(), '', false ); + } + + update_option( self::VERSION_OPTION, PROCOREWP_VERSION, false ); + + if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { + wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::CRON_HOOK ); + } + } + + /** + * Deactivation handler. + * + * @return void + */ + public static function deactivate(): void { + wp_clear_scheduled_hook( self::CRON_HOOK ); + Cache::flush(); + } +} diff --git a/templates/collection.php b/templates/collection.php new file mode 100644 index 0000000..caebd86 --- /dev/null +++ b/templates/collection.php @@ -0,0 +1,61 @@ + $data Template data supplied by the Renderer. + */ + +declare( strict_types = 1 ); + +use ProcoreWP\Support\Format; + +defined( 'ABSPATH' ) || exit; + +$procorewp_rows = (array) ( $data['rows'] ?? array() ); +$procorewp_columns = (array) ( $data['columns'] ?? array() ); +$procorewp_title = (string) ( $data['title'] ?? '' ); +$procorewp_class = (string) ( $data['class'] ?? 'procorewp' ); +$procorewp_show_email = ! empty( $data['show_email'] ); + +if ( empty( $procorewp_columns ) ) { + return; +} +?> +
+ +

+ + +
+ + + + + + + + + + + + + + + + + +
+ +
+
+
diff --git a/templates/field.php b/templates/field.php new file mode 100644 index 0000000..89dfbce --- /dev/null +++ b/templates/field.php @@ -0,0 +1,30 @@ + $data Template data supplied by the Renderer. + */ + +declare( strict_types = 1 ); + +defined( 'ABSPATH' ) || exit; + +$procorewp_label = (string) ( $data['label'] ?? '' ); +$procorewp_value = (string) ( $data['value'] ?? '' ); +$procorewp_field = (string) ( $data['field'] ?? '' ); +$procorewp_class = (string) ( $data['class'] ?? 'procorewp' ); + +if ( '' === $procorewp_value ) { + return; +} +?> +
+ + + + +
diff --git a/templates/image.php b/templates/image.php new file mode 100644 index 0000000..c7778bb --- /dev/null +++ b/templates/image.php @@ -0,0 +1,42 @@ + $data Template data supplied by the Renderer. + */ + +declare( strict_types = 1 ); + +defined( 'ABSPATH' ) || exit; + +$procorewp_url = (string) ( $data['url'] ?? '' ); +$procorewp_alt = (string) ( $data['alt'] ?? '' ); +$procorewp_width = (int) ( $data['width'] ?? 0 ); +$procorewp_height = (int) ( $data['height'] ?? 0 ); +$procorewp_lazy = ! empty( $data['lazy'] ); +$procorewp_class = (string) ( $data['class'] ?? 'procorewp' ); + +if ( '' === $procorewp_url ) { + return; +} +?> +
+ <?php echo esc_attr( $procorewp_alt ); ?> 0 ) : ?> + width="" + + 0 ) : ?> + height="" + + + loading="lazy" decoding="async" + + referrerpolicy="no-referrer" + /> +
diff --git a/templates/map.php b/templates/map.php new file mode 100644 index 0000000..ec9c139 --- /dev/null +++ b/templates/map.php @@ -0,0 +1,65 @@ + $data Template data supplied by the Renderer. + */ + +declare( strict_types = 1 ); + +defined( 'ABSPATH' ) || exit; + +$procorewp_points = (array) ( $data['points'] ?? array() ); +$procorewp_title = (string) ( $data['title'] ?? '' ); +$procorewp_link = ! empty( $data['link'] ); +$procorewp_class = (string) ( $data['class'] ?? 'procorewp' ); + +if ( empty( $procorewp_points ) ) { + return; +} +?> +
+ +

+ + +
    + +
  • + + + + + + + + + + + +
  • + +
+
diff --git a/templates/record.php b/templates/record.php new file mode 100644 index 0000000..b9de0bb --- /dev/null +++ b/templates/record.php @@ -0,0 +1,45 @@ + $data Template data supplied by the Renderer. + */ + +declare( strict_types = 1 ); + +use ProcoreWP\Support\Format; + +defined( 'ABSPATH' ) || exit; + +$procorewp_record = (array) ( $data['record'] ?? array() ); +$procorewp_columns = (array) ( $data['columns'] ?? array() ); +$procorewp_title = (string) ( $data['title'] ?? '' ); +$procorewp_class = (string) ( $data['class'] ?? 'procorewp' ); +$procorewp_show_email = ! empty( $data['show_email'] ); + +if ( empty( $procorewp_record ) ) { + return; +} +?> +
+ +

+ + +
+ + + + + +
+
+
+
+ +
+
diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 0000000..77af380 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,64 @@ +query( + $wpdb->prepare( + "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s", + $wpdb->esc_like( '_transient_procorewp_' ) . '%', + $wpdb->esc_like( '_transient_timeout_procorewp_' ) . '%', + $wpdb->esc_like( '_site_transient_procorewp_' ) . '%', + $wpdb->esc_like( '_site_transient_timeout_procorewp_' ) . '%' + ) +); +// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange + +wp_cache_flush(); From 75e8af16e326c73fe20b6afff933bbd4444d5ddf Mon Sep 17 00:00:00 2001 From: "Matthew M. Emma" Date: Fri, 7 Aug 2026 10:42:33 -0700 Subject: [PATCH 4/9] Add the admin panel and a diagnostic that reports something useful A tabbed Procore menu: Connection, Display, Cache, Shortcodes, Status and Tools. The connection test replaces 1.x's binary success/failure. It reports six stages separately and then probes every registered endpoint, reporting each as readable, failed or skipped alongside the Procore permission it needs. The common real-world failure is a perfectly valid token whose service account lacks read permission on one tool, and "connection successful" tells you nothing about that. Every admin action verifies a nonce and the manage_options capability. 1.x ran its connection test from an unverified $_POST key. The client secret is never rendered back into the form. The field shows a mask, submitting it blank keeps the stored value, and there is an explicit checkbox to delete it. 1.x echoed the real secret into an input value attribute. The OAuth round trip binds a single-use state value to a transient and refuses any callback that does not match, which is what stops someone grafting their own Procore account onto the site via a crafted URL. The Shortcodes tab is generated from the same registry the shortcodes use, so the reference cannot drift from the code. Status surfaces rate-limit headroom, circuit state, token expiry, cache statistics and a redacted diagnostic log. admin.js is plain ES5, no build step. Co-Authored-By: Claude Opus 5 --- assets/css/admin.css | 77 ++++ assets/js/admin.js | 326 +++++++++++++ src/Admin/Ajax.php | 175 +++++++ src/Admin/ConnectionTester.php | 426 +++++++++++++++++ src/Admin/Notices.php | 198 ++++++++ src/Admin/OAuthController.php | 117 +++++ src/Admin/SettingsPage.php | 817 +++++++++++++++++++++++++++++++++ 7 files changed, 2136 insertions(+) create mode 100644 assets/css/admin.css create mode 100644 assets/js/admin.js create mode 100644 src/Admin/Ajax.php create mode 100644 src/Admin/ConnectionTester.php create mode 100644 src/Admin/Notices.php create mode 100644 src/Admin/OAuthController.php create mode 100644 src/Admin/SettingsPage.php diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..e36ec34 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,77 @@ +/*! + * ProcoreWP admin styles. + */ + +.procorewp-admin .nav-tab-wrapper { + margin-bottom: 1.25rem; +} + +.procorewp-admin code.procorewp-copy { + cursor: pointer; + position: relative; +} + +.procorewp-admin code.procorewp-copy.is-copied::after { + content: attr(data-copied-label, "Copied"); + position: absolute; + inset-inline-start: 100%; + margin-inline-start: 0.5rem; + white-space: nowrap; + color: #1d7a3f; + font-weight: 600; +} + +.procorewp-steps { + margin: 0 0 1.5rem; + padding: 0; + list-style: none; + max-width: 60rem; +} + +.procorewp-step { + display: flex; + flex-wrap: wrap; + gap: 0.25rem 1rem; + padding: 0.625rem 0.875rem; + border-left: 4px solid #dcdcde; + background: #fff; + margin-bottom: 0.375rem; +} + +.procorewp-step strong { + min-width: 12rem; +} + +.procorewp-step--ok { + border-left-color: #1d7a3f; +} + +.procorewp-step--fail { + border-left-color: #b32d2e; +} + +.procorewp-step__message { + flex: 1 1 20rem; + color: #50575e; +} + +.procorewp-probes { + max-width: 60rem; +} + +.procorewp-probe--ok { + color: #1d7a3f; +} + +.procorewp-probe--failed { + color: #b32d2e; +} + +.procorewp-probe--skipped { + color: #7c7c7c; + font-style: italic; +} + +#procorewp-company-list { + margin-top: 0.5rem; +} diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..9b98755 --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,326 @@ +/** + * ProcoreWP admin behaviour. + * + * Plain ES5 with no build step, so the shipped file is the source file. + * + * @package ProcoreWP + */ + +( function () { + 'use strict'; + + var config = window.procorewpAdmin || {}; + var strings = config.strings || {}; + + /** + * POST to admin-ajax with the shared nonce. + * + * @param {string} action Ajax action name. + * @param {Object} data Additional payload. + * @param {Function} onDone Success callback. + * @param {Function} onFail Failure callback. + */ + function post( action, data, onDone, onFail ) { + var body = new window.FormData(); + + body.append( 'action', action ); + body.append( 'nonce', config.nonce ); + + Object.keys( data || {} ).forEach( function ( key ) { + body.append( key, data[ key ] ); + } ); + + window + .fetch( config.ajaxUrl, { + method: 'POST', + credentials: 'same-origin', + body: body, + } ) + .then( function ( response ) { + return response.json(); + } ) + .then( function ( payload ) { + if ( payload && payload.success ) { + onDone( payload.data || {} ); + } else { + onFail( ( payload && payload.data && payload.data.message ) || strings.failed ); + } + } ) + .catch( function () { + onFail( strings.failed ); + } ); + } + + /** + * Create an element with text content. + * + * @param {string} tag Tag name. + * @param {string} className Class attribute. + * @param {string} text Text content. + * @return {HTMLElement} The element. + */ + function el( tag, className, text ) { + var node = document.createElement( tag ); + + if ( className ) { + node.className = className; + } + + if ( undefined !== text && null !== text ) { + node.textContent = text; + } + + return node; + } + + /** + * Render the connection diagnostic report. + * + * @param {Object} report Report payload. + */ + function renderReport( report ) { + var target = document.getElementById( 'procorewp-test-results' ); + + if ( ! target ) { + return; + } + + target.innerHTML = ''; + + var summary = el( + 'div', + 'notice notice-' + ( report.ok ? 'success' : 'warning' ) + ' procorewp-summary' + ); + summary.appendChild( el( 'p', '', report.summary ) ); + target.appendChild( summary ); + + var list = el( 'ul', 'procorewp-steps' ); + + ( report.steps || [] ).forEach( function ( step ) { + var item = el( 'li', 'procorewp-step procorewp-step--' + ( step.ok ? 'ok' : 'fail' ) ); + + item.appendChild( el( 'strong', '', step.label ) ); + item.appendChild( el( 'span', 'procorewp-step__message', step.message ) ); + list.appendChild( item ); + } ); + + target.appendChild( list ); + + if ( ! report.probes || ! report.probes.length ) { + return; + } + + target.appendChild( el( 'h3', '', 'Endpoint permissions' ) ); + + var table = el( 'table', 'widefat striped procorewp-probes' ); + var tbody = document.createElement( 'tbody' ); + + report.probes.forEach( function ( probe ) { + var row = document.createElement( 'tr' ); + + row.appendChild( el( 'td', '', probe.label ) ); + row.appendChild( + el( 'td', 'procorewp-probe--' + probe.status, probe.message ) + ); + row.appendChild( el( 'td', '', probe.permission ) ); + tbody.appendChild( row ); + } ); + + table.appendChild( tbody ); + target.appendChild( table ); + } + + /** + * Wire the connection test button. + */ + function bindTest() { + var button = document.getElementById( 'procorewp-test' ); + + if ( ! button ) { + return; + } + + button.addEventListener( 'click', function () { + var original = button.textContent; + + button.disabled = true; + button.textContent = strings.testing; + + post( + 'procorewp_test_connection', + {}, + function ( report ) { + button.disabled = false; + button.textContent = original; + renderReport( report ); + }, + function ( message ) { + button.disabled = false; + button.textContent = original; + renderReport( { ok: false, summary: message, steps: [], probes: [] } ); + } + ); + } ); + } + + /** + * Wire the cache purge button. + */ + function bindCache() { + var button = document.getElementById( 'procorewp-clear-cache' ); + var message = document.getElementById( 'procorewp-cache-message' ); + + if ( ! button ) { + return; + } + + button.addEventListener( 'click', function () { + button.disabled = true; + + if ( message ) { + message.textContent = strings.clearing; + } + + post( + 'procorewp_clear_cache', + {}, + function ( data ) { + button.disabled = false; + + if ( message ) { + message.textContent = data.message || ''; + } + }, + function ( text ) { + button.disabled = false; + + if ( message ) { + message.textContent = text; + } + } + ); + } ); + } + + /** + * Wire the company lookup button. + */ + function bindCompanies() { + var button = document.getElementById( 'procorewp-load-companies' ); + var list = document.getElementById( 'procorewp-company-list' ); + var input = document.getElementById( 'procorewp-company-id' ); + + if ( ! button || ! list ) { + return; + } + + button.addEventListener( 'click', function () { + button.disabled = true; + list.textContent = strings.loading; + + post( + 'procorewp_companies', + {}, + function ( data ) { + button.disabled = false; + list.innerHTML = ''; + + var select = document.createElement( 'select' ); + + ( data.companies || [] ).forEach( function ( company ) { + var option = document.createElement( 'option' ); + + option.value = company.id; + option.textContent = company.name + ' (' + company.id + ')'; + + if ( input && String( company.id ) === String( input.value ) ) { + option.selected = true; + } + + select.appendChild( option ); + } ); + + select.addEventListener( 'change', function () { + if ( input ) { + input.value = select.value; + } + } ); + + list.appendChild( select ); + }, + function ( text ) { + button.disabled = false; + list.textContent = text; + } + ); + } ); + } + + /** + * Wire the disconnect button. + */ + function bindDisconnect() { + var button = document.getElementById( 'procorewp-disconnect' ); + + if ( ! button ) { + return; + } + + button.addEventListener( 'click', function () { + if ( ! window.confirm( strings.confirmed ) ) { + return; + } + + button.disabled = true; + + post( + 'procorewp_disconnect', + {}, + function () { + window.location.reload(); + }, + function () { + button.disabled = false; + } + ); + } ); + } + + /** + * Wire click-to-copy on code elements. + */ + function bindCopy() { + document.addEventListener( 'click', function ( event ) { + var trigger = event.target.closest( '[data-procorewp-copy], [data-procorewp-copy-button]' ); + + if ( ! trigger ) { + return; + } + + var source = trigger.hasAttribute( 'data-procorewp-copy' ) + ? trigger + : trigger.parentNode.querySelector( '[data-procorewp-copy]' ); + + if ( ! source || ! window.navigator.clipboard ) { + return; + } + + window.navigator.clipboard + .writeText( source.getAttribute( 'data-procorewp-copy' ) ) + .then( function () { + source.classList.add( 'is-copied' ); + window.setTimeout( function () { + source.classList.remove( 'is-copied' ); + }, 1500 ); + } ); + } ); + } + + document.addEventListener( 'DOMContentLoaded', function () { + bindTest(); + bindCache(); + bindCompanies(); + bindDisconnect(); + bindCopy(); + } ); +} )(); diff --git a/src/Admin/Ajax.php b/src/Admin/Ajax.php new file mode 100644 index 0000000..042f8b1 --- /dev/null +++ b/src/Admin/Ajax.php @@ -0,0 +1,175 @@ +authorise(); + + wp_send_json_success( ( new ConnectionTester() )->run() ); + } + + /** + * Purge cached Procore responses. + * + * @return void + */ + public function clear_cache(): void { + $this->authorise(); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- authorise() calls check_ajax_referer() immediately above. + $group = isset( $_POST['group'] ) ? sanitize_key( wp_unslash( $_POST['group'] ) ) : ''; + $removed = Cache::flush( $group ); + + Client::reset_circuit(); + + wp_send_json_success( + array( + 'removed' => $removed, + 'message' => sprintf( + /* translators: %d: number of cache entries removed. */ + _n( 'Removed %d cached response.', 'Removed %d cached responses.', $removed, 'procorewp' ), + $removed + ), + 'stats' => Cache::stats(), + ) + ); + } + + /** + * List the companies available to the stored credentials. + * + * @return void + */ + public function companies(): void { + $this->authorise(); + + $companies = ( new ConnectionTester() )->companies(); + + if ( is_wp_error( $companies ) ) { + wp_send_json_error( array( 'message' => $companies->get_error_message() ) ); + } + + $options = array(); + + foreach ( (array) $companies as $company ) { + $options[] = array( + 'id' => absint( Arr::get( $company, 'id', 0 ) ), + 'name' => Arr::str( $company, 'name' ), + ); + } + + wp_send_json_success( array( 'companies' => $options ) ); + } + + /** + * List projects for a company, used to populate block and settings pickers. + * + * @return void + */ + public function projects(): void { + $this->authorise(); + + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- authorise() calls check_ajax_referer() immediately above. + $company = isset( $_POST['company_id'] ) ? absint( wp_unslash( $_POST['company_id'] ) ) : Settings::default_company_id(); + $projects = Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'all' => true, + ) + ); + + if ( is_wp_error( $projects ) ) { + wp_send_json_error( array( 'message' => $projects->get_error_message() ) ); + } + + $options = array(); + + foreach ( (array) $projects as $project ) { + $options[] = array( + 'id' => absint( Arr::get( $project, 'id', 0 ) ), + 'name' => Arr::str( $project, 'name' ), + 'active' => (bool) Arr::get( $project, 'active', true ), + ); + } + + wp_send_json_success( array( 'projects' => $options ) ); + } + + /** + * Discard the stored tokens. + * + * @return void + */ + public function disconnect(): void { + $this->authorise(); + + TokenStore::clear(); + Cache::flush(); + Client::reset_circuit(); + Logger::info( 'Procore connection reset from the admin screen.' ); + + wp_send_json_success( array( 'message' => __( 'Disconnected from Procore.', 'procorewp' ) ) ); + } + + /** + * Verify the nonce and capability, terminating the request on failure. + * + * @return void + */ + private function authorise(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( array( 'message' => __( 'You do not have permission to do that.', 'procorewp' ) ), 403 ); + } + + check_ajax_referer( self::NONCE, 'nonce' ); + } +} diff --git a/src/Admin/ConnectionTester.php b/src/Admin/ConnectionTester.php new file mode 100644 index 0000000..c7235e4 --- /dev/null +++ b/src/Admin/ConnectionTester.php @@ -0,0 +1,426 @@ + Report with `steps`, `probes` and `summary`. + */ + public function run(): array { + $steps = array(); + + $steps[] = $this->check_prerequisites(); + + $token = $this->check_token(); + $steps[] = $token; + + if ( ! $token['ok'] ) { + return $this->report( $steps, array() ); + } + + $identity = $this->check_identity(); + $steps[] = $identity; + + $companies = $this->check_companies(); + $steps[] = $companies; + + $company = $this->check_company_access(); + $steps[] = $company; + + $steps[] = $this->check_rate_limit(); + + $probes = $company['ok'] ? $this->probe_endpoints() : array(); + + return $this->report( $steps, $probes ); + } + + /** + * Confirm the plugin has what it needs before contacting Procore. + * + * @return array Step result. + */ + private function check_prerequisites(): array { + $issues = array(); + + if ( '' === Settings::client_id() ) { + $issues[] = __( 'No Client ID is set.', 'procorewp' ); + } + + if ( '' === Settings::client_secret() ) { + $issues[] = __( 'No Client Secret is set.', 'procorewp' ); + } + + if ( ! Encryption::is_strong() ) { + $issues[] = __( 'OpenSSL is unavailable, so credentials cannot be encrypted at rest. Define PROCOREWP_CLIENT_SECRET in wp-config.php instead.', 'procorewp' ); + } + + return $this->step( + __( 'Configuration', 'procorewp' ), + empty( $issues ), + empty( $issues ) + ? sprintf( + /* translators: 1: environment label, 2: API host. */ + __( 'Ready. Environment: %1$s (%2$s).', 'procorewp' ), + Environment::choices()[ Environment::current() ], + Environment::api_host() + ) + : implode( ' ', $issues ) + ); + } + + /** + * Obtain an access token. + * + * @return array Step result. + */ + private function check_token(): array { + $client = Client::instance(); + $auth = $client->auth(); + + if ( ! $auth->is_configured() ) { + return $this->step( __( 'Access token', 'procorewp' ), false, __( 'Credentials are incomplete.', 'procorewp' ) ); + } + + $token = $auth->access_token(); + + if ( is_wp_error( $token ) ) { + return $this->step( __( 'Access token', 'procorewp' ), false, $token->get_error_message() ); + } + + $stored = TokenStore::all(); + $expires = (int) $stored['expires_at']; + + return $this->step( + __( 'Access token', 'procorewp' ), + true, + sprintf( + /* translators: 1: grant type label, 2: human readable time difference. */ + __( 'Obtained via %1$s. Valid for another %2$s.', 'procorewp' ), + $auth->label(), + human_time_diff( time(), max( $expires, time() ) ) + ) + ); + } + + /** + * Read the authenticated account. + * + * @return array Step result. + */ + private function check_identity(): array { + $me = Client::instance()->fetch( 'me', array(), array( 'bypass_cache' => true ) ); + + if ( is_wp_error( $me ) ) { + return $this->step( __( 'Authenticated account', 'procorewp' ), false, $me->get_error_message() ); + } + + $name = Arr::str( $me, 'name' ); + $login = Arr::str( $me, 'login' ); + + return $this->step( + __( 'Authenticated account', 'procorewp' ), + true, + '' !== $name || '' !== $login + ? sprintf( + /* translators: 1: account display name, 2: account login. */ + __( 'Connected as %1$s (%2$s).', 'procorewp' ), + '' !== $name ? $name : __( 'service account', 'procorewp' ), + '' !== $login ? $login : __( 'no login', 'procorewp' ) + ) + : __( 'Connected.', 'procorewp' ) + ); + } + + /** + * List the companies the credentials can reach. + * + * @return array Step result. + */ + private function check_companies(): array { + $companies = $this->companies(); + + if ( is_wp_error( $companies ) ) { + return $this->step( __( 'Company access', 'procorewp' ), false, $companies->get_error_message() ); + } + + if ( empty( $companies ) ) { + return $this->step( + __( 'Company access', 'procorewp' ), + false, + __( 'Procore returned no companies. Ask a company administrator to install the app and grant it access.', 'procorewp' ) + ); + } + + $names = array(); + + foreach ( array_slice( $companies, 0, 10 ) as $company ) { + $names[] = sprintf( '%s (%d)', Arr::str( $company, 'name' ), absint( Arr::get( $company, 'id', 0 ) ) ); + } + + return $this->step( + __( 'Company access', 'procorewp' ), + true, + sprintf( + /* translators: 1: number of companies, 2: comma-separated company names. */ + _n( '%1$d company available: %2$s', '%1$d companies available: %2$s', count( $companies ), 'procorewp' ), + count( $companies ), + implode( ', ', $names ) + ) + ); + } + + /** + * Confirm the configured default company is usable. + * + * @return array Step result. + */ + private function check_company_access(): array { + $company = Settings::default_company_id(); + + if ( $company <= 0 ) { + return $this->step( + __( 'Default company', 'procorewp' ), + false, + __( 'No default company is set. Choose one below, or pass company_id in every shortcode.', 'procorewp' ) + ); + } + + $projects = Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'bypass_cache' => true, + 'per_page' => 1, + ) + ); + + if ( is_wp_error( $projects ) ) { + return $this->step( __( 'Default company', 'procorewp' ), false, $projects->get_error_message() ); + } + + $total = Client::instance()->meta()['total']; + + return $this->step( + __( 'Default company', 'procorewp' ), + true, + null === $total + ? sprintf( + /* translators: %d: company ID. */ + __( 'Projects are readable for company %d.', 'procorewp' ), + $company + ) + : sprintf( + /* translators: 1: number of projects, 2: company ID. */ + _n( '%1$d project visible in company %2$d.', '%1$d projects visible in company %2$d.', (int) $total, 'procorewp' ), + (int) $total, + $company + ) + ); + } + + /** + * Report the remaining rate-limit headroom. + * + * @return array Step result. + */ + private function check_rate_limit(): array { + $limit = Client::rate_limit(); + + if ( 0 === $limit['limit'] ) { + return $this->step( __( 'Rate limit', 'procorewp' ), true, __( 'Procore has not reported a rate limit yet.', 'procorewp' ) ); + } + + $healthy = $limit['limit'] < 1 || ( $limit['remaining'] / max( 1, $limit['limit'] ) ) > 0.1; + + return $this->step( + __( 'Rate limit', 'procorewp' ), + $healthy, + sprintf( + /* translators: 1: remaining requests, 2: total allowed requests, 3: human readable time until reset. */ + __( '%1$d of %2$d requests remaining; resets in %3$s.', 'procorewp' ), + $limit['remaining'], + $limit['limit'], + human_time_diff( time(), max( $limit['reset'], time() ) ) + ) + ); + } + + /** + * Probe every registered endpoint to reveal which tool permissions apply. + * + * @return array> Probe results. + */ + private function probe_endpoints(): array { + $company = Settings::default_company_id(); + $project = $this->sample_project_id( $company ); + $results = array(); + + foreach ( Endpoints::all() as $slug => $definition ) { + if ( in_array( $slug, array( 'me', 'companies' ), true ) ) { + continue; + } + + if ( Endpoints::SCOPE_PROJECT === $definition['scope'] && $project <= 0 ) { + $results[] = array( + 'slug' => $slug, + 'label' => (string) $definition['label'], + 'permission' => (string) $definition['permission'], + 'status' => 'skipped', + 'message' => __( 'No project was available to test against.', 'procorewp' ), + ); + continue; + } + + $response = Client::instance()->fetch( + $slug, + array(), + array( + 'company_id' => $company, + 'project_id' => $project, + 'per_page' => 1, + 'bypass_cache' => true, + ) + ); + + if ( is_wp_error( $response ) ) { + $results[] = array( + 'slug' => $slug, + 'label' => (string) $definition['label'], + 'permission' => (string) $definition['permission'], + 'status' => 'failed', + 'message' => $response->get_error_message(), + ); + continue; + } + + $results[] = array( + 'slug' => $slug, + 'label' => (string) $definition['label'], + 'permission' => (string) $definition['permission'], + 'status' => 'ok', + 'message' => __( 'Readable.', 'procorewp' ), + ); + }//end foreach + + return $results; + } + + /** + * Find a project identifier to probe project-scoped endpoints against. + * + * @param int $company Company identifier. + * @return int Project identifier, or zero when none is available. + */ + private function sample_project_id( int $company ): int { + $configured = Settings::default_project_id(); + + if ( $configured > 0 ) { + return $configured; + } + + $projects = Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'per_page' => 1, + ) + ); + + if ( is_wp_error( $projects ) || ! is_array( $projects ) || empty( $projects ) ) { + return 0; + } + + return absint( Arr::get( reset( $projects ), 'id', 0 ) ); + } + + /** + * Retrieve the companies available to the current credentials. + * + * @return array|\WP_Error Companies, or an error. + */ + public function companies() { + $companies = Client::instance()->fetch( 'companies', array(), array( 'bypass_cache' => true ) ); + + if ( is_wp_error( $companies ) ) { + return $companies; + } + + return is_array( $companies ) ? $companies : array(); + } + + /** + * Build a step result. + * + * @param string $label Step name. + * @param bool $ok Whether the step succeeded. + * @param string $message Detail message. + * @return array Step result. + */ + private function step( string $label, bool $ok, string $message ): array { + return array( + 'label' => $label, + 'ok' => $ok, + 'message' => $message, + ); + } + + /** + * Assemble the final report. + * + * @param array> $steps Step results. + * @param array> $probes Endpoint probe results. + * @return array Report. + */ + private function report( array $steps, array $probes ): array { + $failed = 0; + + foreach ( $steps as $step ) { + if ( ! $step['ok'] ) { + ++$failed; + } + } + + return array( + 'steps' => $steps, + 'probes' => $probes, + 'ok' => 0 === $failed, + 'summary' => 0 === $failed + ? __( 'Everything checked out.', 'procorewp' ) + : sprintf( + /* translators: %d: number of failed checks. */ + _n( '%d check needs attention.', '%d checks need attention.', $failed, 'procorewp' ), + $failed + ), + ); + } +} diff --git a/src/Admin/Notices.php b/src/Admin/Notices.php new file mode 100644 index 0000000..4da7a25 --- /dev/null +++ b/src/Admin/Notices.php @@ -0,0 +1,198 @@ +oauth_result(); + $this->migration_notice(); + + if ( ! $this->on_plugin_screen() ) { + return; + } + + $this->encryption_notice(); + $this->environment_notice(); + $this->circuit_notice(); + } + + /** + * Report the outcome of an Authorization Code round trip. + * + * @return void + */ + private function oauth_result(): void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only display flag set by our own redirect. + $status = isset( $_GET['procorewp_oauth'] ) ? sanitize_key( wp_unslash( $_GET['procorewp_oauth'] ) ) : ''; + + if ( '' === $status ) { + return; + } + + if ( 'connected' === $status ) { + $this->notice( 'success', __( 'Connected to Procore.', 'procorewp' ) ); + + return; + } + + $stored_error = (string) get_transient( 'procorewp_oauth_error' ); + + $messages = array( + 'denied' => __( 'The Procore authorization request was declined.', 'procorewp' ), + 'missing_credentials' => __( 'Enter a Client ID and Client Secret before connecting.', 'procorewp' ), + 'failed' => '' !== $stored_error ? $stored_error : __( 'The Procore connection could not be completed.', 'procorewp' ), + ); + + delete_transient( 'procorewp_oauth_error' ); + + if ( isset( $messages[ $status ] ) ) { + $this->notice( 'error', $messages[ $status ] ); + } + } + + /** + * Point administrators at the settings screen after a 1.x upgrade. + * + * @return void + */ + private function migration_notice(): void { + if ( '1.x' !== get_option( 'procorewp_migrated_from' ) ) { + return; + } + + $this->notice( + 'warning', + sprintf( + /* translators: %s: settings screen URL. */ + __( 'ProcoreWP imported your settings from version 1.x. Version 1.x authenticated against the wrong Procore host, so you need to re-run the connection test before shortcodes will return data.', 'procorewp' ), + esc_url( admin_url( 'admin.php?page=procorewp' ) ) + ), + true + ); + + delete_option( 'procorewp_migrated_from' ); + } + + /** + * Warn when credentials cannot be encrypted at rest. + * + * @return void + */ + private function encryption_notice(): void { + if ( Encryption::is_strong() || Settings::credentials_are_constants() ) { + return; + } + + $this->notice( + 'warning', + __( 'OpenSSL is not available on this server, so your Procore Client Secret cannot be encrypted in the database. Define PROCOREWP_CLIENT_SECRET in wp-config.php instead.', 'procorewp' ) + ); + } + + /** + * Make a non-production environment obvious. + * + * @return void + */ + private function environment_notice(): void { + if ( Environment::is_production() ) { + return; + } + + $this->notice( + 'info', + sprintf( + /* translators: 1: environment label, 2: API host. */ + __( 'ProcoreWP is pointed at %1$s (%2$s). Front-end shortcodes are showing sandbox data.', 'procorewp' ), + Environment::choices()[ Environment::current() ], + Environment::api_host() + ) + ); + } + + /** + * Report an open circuit breaker. + * + * @return void + */ + private function circuit_notice(): void { + $state = Client::circuit_state(); + + if ( $state['open_until'] <= time() ) { + return; + } + + $this->notice( + 'error', + sprintf( + /* translators: %s: human readable time difference. */ + __( 'Procore requests are paused after repeated failures and will resume in %s. Cached data is being served in the meantime.', 'procorewp' ), + human_time_diff( time(), $state['open_until'] ) + ) + ); + } + + /** + * Whether the current screen belongs to this plugin. + * + * @return bool True on a ProcoreWP screen. + */ + private function on_plugin_screen(): bool { + $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null; + + return $screen instanceof \WP_Screen && false !== strpos( (string) $screen->id, 'procorewp' ); + } + + /** + * Print a notice. + * + * @param string $type Notice type: success, warning, error or info. + * @param string $message Message text. + * @param bool $allow_html Whether the message contains a permitted link. + * @return void + */ + private function notice( string $type, string $message, bool $allow_html = false ): void { + printf( + '

%2$s

', + esc_attr( $type ), + $allow_html + ? wp_kses( $message, array( 'a' => array( 'href' => array() ) ) ) + : esc_html( $message ) + ); + } +} diff --git a/src/Admin/OAuthController.php b/src/Admin/OAuthController.php new file mode 100644 index 0000000..c50f47d --- /dev/null +++ b/src/Admin/OAuthController.php @@ -0,0 +1,117 @@ + 403 ) ); + } + + check_admin_referer( self::NONCE ); + + $auth = new AuthorizationCode(); + + if ( ! $auth->is_configured() ) { + $this->redirect_back( 'missing_credentials' ); + } + + /* + * wp_safe_redirect() is deliberately not used: the destination is + * Procore's own authorization screen on an external host, which the + * safe-redirect allow-list would block. The URL is built entirely from + * the configured environment, never from request input. + */ + // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- External OAuth provider, URL built from trusted settings. + wp_redirect( $auth->authorization_url() ); + exit; + } + + /** + * Handle Procore's redirect back to this site. + * + * @return void + */ + public function callback(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to complete this connection.', 'procorewp' ), '', array( 'response' => 403 ) ); + } + + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Verified via the one-time OAuth state parameter below. + if ( isset( $_GET['error'] ) ) { + $this->redirect_back( 'denied' ); + } + + $code = isset( $_GET['code'] ) ? sanitize_text_field( wp_unslash( $_GET['code'] ) ) : ''; + $state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : ''; + // phpcs:enable WordPress.Security.NonceVerification.Recommended + + $result = ( new AuthorizationCode() )->exchange_code( $code, $state ); + + if ( is_wp_error( $result ) ) { + set_transient( 'procorewp_oauth_error', $result->get_error_message(), MINUTE_IN_SECONDS * 5 ); + $this->redirect_back( 'failed' ); + } + + $this->redirect_back( 'connected' ); + } + + /** + * Return to the settings screen carrying a status flag. + * + * @param string $status Status slug appended to the URL. + * @return void + */ + private function redirect_back( string $status ): void { + wp_safe_redirect( + add_query_arg( + array( + 'page' => 'procorewp', + 'procorewp_oauth' => $status, + ), + admin_url( 'admin.php' ) + ) + ); + exit; + } +} diff --git a/src/Admin/SettingsPage.php b/src/Admin/SettingsPage.php new file mode 100644 index 0000000..8d073c4 --- /dev/null +++ b/src/Admin/SettingsPage.php @@ -0,0 +1,817 @@ + 'array', + 'sanitize_callback' => array( Settings::class, 'sanitize' ), + 'default' => Settings::defaults(), + 'show_in_rest' => false, + ) + ); + } + + /** + * Enqueue the admin script on this plugin's screens only. + * + * @param string $hook Current admin page hook suffix. + * @return void + */ + public function enqueue( string $hook ): void { + if ( 'toplevel_page_' . self::SLUG !== $hook ) { + return; + } + + wp_enqueue_script( + 'procorewp-admin', + PROCOREWP_URL . 'assets/js/admin.js', + array( 'wp-i18n' ), + PROCOREWP_VERSION, + true + ); + + wp_localize_script( + 'procorewp-admin', + 'procorewpAdmin', + array( + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( Ajax::NONCE ), + 'strings' => array( + 'testing' => __( 'Running checks…', 'procorewp' ), + 'clearing' => __( 'Clearing…', 'procorewp' ), + 'loading' => __( 'Loading…', 'procorewp' ), + 'failed' => __( 'The request failed. Check your connection and try again.', 'procorewp' ), + 'copied' => __( 'Copied', 'procorewp' ), + 'confirmed' => __( 'Disconnect this site from Procore?', 'procorewp' ), + 'skipped' => __( 'Skipped', 'procorewp' ), + ), + ) + ); + + wp_enqueue_style( + 'procorewp-admin', + PROCOREWP_URL . 'assets/css/admin.css', + array(), + PROCOREWP_VERSION + ); + } + + /** + * Render the current tab. + * + * @return void + */ + public function render(): void { + if ( ! current_user_can( self::CAPABILITY ) ) { + wp_die( esc_html__( 'You do not have permission to view this page.', 'procorewp' ), '', array( 'response' => 403 ) ); + } + + $tabs = array( + 'connection' => __( 'Connection', 'procorewp' ), + 'display' => __( 'Display', 'procorewp' ), + 'cache' => __( 'Cache', 'procorewp' ), + 'shortcodes' => __( 'Shortcodes', 'procorewp' ), + 'status' => __( 'Status', 'procorewp' ), + 'tools' => __( 'Tools', 'procorewp' ), + ); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only tab selector. + $active = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'connection'; + $active = array_key_exists( $active, $tabs ) ? $active : 'connection'; + + echo '
'; + echo '

' . esc_html__( 'ProcoreWP', 'procorewp' ) . '

'; + + echo ''; + + switch ( $active ) { + case 'display': + $this->render_form( array( $this, 'fields_display' ) ); + break; + + case 'cache': + $this->render_form( array( $this, 'fields_cache' ) ); + break; + + case 'shortcodes': + $this->render_shortcodes(); + break; + + case 'status': + $this->render_status(); + break; + + case 'tools': + $this->render_form( array( $this, 'fields_tools' ) ); + break; + + default: + $this->render_connection(); + break; + }//end switch + + echo '
'; + } + + /** + * Wrap a set of fields in the options form. + * + * @param callable $fields Callback that prints the table rows. + * @return void + */ + private function render_form( callable $fields ): void { + echo '
'; + settings_fields( Settings::GROUP ); + echo ''; + call_user_func( $fields ); + echo ''; + submit_button(); + echo '
'; + } + + /** + * Render the Connection tab. + * + * @return void + */ + private function render_connection(): void { + $this->render_form( array( $this, 'fields_connection' ) ); + + $auth_mode = (string) Settings::get( 'auth_mode', 'client_credentials' ); + + if ( 'authorization_code' === $auth_mode ) { + echo '
'; + echo '

' . esc_html__( 'Authorize this site', 'procorewp' ) . '

'; + echo '

' . esc_html__( 'Register this exact redirect URI in the Procore Developer Portal before connecting:', 'procorewp' ) . '

'; + + printf( + '

%1$s

', + esc_attr( Environment::redirect_uri() ), + esc_html__( 'Copy', 'procorewp' ) + ); + + echo '

'; + + if ( '' !== TokenStore::refresh_token() ) { + printf( + '', + esc_html__( 'Disconnect from Procore', 'procorewp' ) + ); + } else { + printf( + '%s', + esc_url( wp_nonce_url( admin_url( 'admin-post.php?action=procorewp_oauth_connect' ), OAuthController::NONCE ) ), + esc_html__( 'Connect to Procore', 'procorewp' ) + ); + } + + echo '

'; + }//end if + + echo '
'; + echo '

' . esc_html__( 'Verify the connection', 'procorewp' ) . '

'; + echo '

' . esc_html__( 'Runs through authentication, company access and every endpoint the shortcodes use, so you can see exactly which Procore tool permissions your credentials have.', 'procorewp' ) . '

'; + printf( + '

', + esc_html__( 'Test connection', 'procorewp' ) + ); + echo '
'; + } + + /** + * Print the Connection tab fields. + * + * @return void + */ + private function fields_connection(): void { + $settings = Settings::all(); + $constants = Settings::credentials_are_constants(); + $has_secret = '' !== Settings::client_secret(); + + $this->row( + __( 'Authentication', 'procorewp' ), + function () use ( $settings ) { + $modes = array( + 'client_credentials' => __( 'Service Account (Client Credentials) — recommended', 'procorewp' ), + 'authorization_code' => __( 'User Account (Authorization Code)', 'procorewp' ), + ); + + foreach ( $modes as $value => $label ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $value ), + checked( $settings['auth_mode'], $value, false ), + esc_html( $label ) + ); + } + + echo '

' . esc_html__( 'A service account keeps working when staff change and is installed by a Procore company administrator from the App Marketplace. A user connection inherits one person\'s permissions and breaks if their access is revoked.', 'procorewp' ) . '

'; + } + ); + + $this->row( + __( 'Environment', 'procorewp' ), + function () use ( $settings ) { + printf( ''; + echo '

' . esc_html__( 'Each environment needs its own credentials; tokens are not shared between them.', 'procorewp' ) . '

'; + + printf( + '

', + esc_html__( 'Custom login host', 'procorewp' ), + esc_attr( Settings::OPTION ), + esc_attr( (string) $settings['custom_login_url'] ) + ); + + printf( + '

', + esc_html__( 'Custom API host', 'procorewp' ), + esc_attr( Settings::OPTION ), + esc_attr( (string) $settings['custom_api_url'] ) + ); + + echo '

' . esc_html__( 'Only used when the environment is set to Custom, for regional and federal zones.', 'procorewp' ) . '

'; + } + ); + + if ( $constants ) { + $this->row( + __( 'Credentials', 'procorewp' ), + function () { + echo '

' . esc_html__( 'Credentials are supplied by PROCOREWP_CLIENT_ID and PROCOREWP_CLIENT_SECRET in wp-config.php and are not stored in the database. Remove those constants to manage credentials here.', 'procorewp' ) . '

'; + } + ); + } else { + $this->row( + __( 'Client ID', 'procorewp' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( (string) $settings['client_id'] ) + ); + } + ); + + $this->row( + __( 'Client Secret', 'procorewp' ), + function () use ( $has_secret ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $has_secret ? Encryption::mask( Settings::client_secret() ) : __( 'Not set', 'procorewp' ) ) + ); + + echo '

' . esc_html__( 'The stored secret is never shown. Leave this blank to keep the current value.', 'procorewp' ) . '

'; + + if ( $has_secret ) { + printf( + '

', + esc_attr( Settings::OPTION ), + esc_html__( 'Delete the stored secret', 'procorewp' ) + ); + } + + echo '

' . esc_html__( 'For the strongest protection, define PROCOREWP_CLIENT_SECRET in wp-config.php instead of storing it here.', 'procorewp' ) . '

'; + } + ); + }//end if + + $this->row( + __( 'Default company', 'procorewp' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + absint( $settings['default_company_id'] ) + ); + + printf( + ' ', + esc_html__( 'Look up companies', 'procorewp' ) + ); + + echo '
'; + echo '

' . esc_html__( 'Used whenever a shortcode omits company_id. Also visible in your Procore URL: app.procore.com/companies/XXXX/.', 'procorewp' ) . '

'; + } + ); + + $this->row( + __( 'Default project', 'procorewp' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + absint( $settings['default_project_id'] ) + ); + + echo '

' . esc_html__( 'Optional. Used whenever a project-scoped shortcode omits project_id.', 'procorewp' ) . '

'; + } + ); + } + + /** + * Print the Display tab fields. + * + * @return void + */ + private function fields_display(): void { + $settings = Settings::all(); + + $this->checkbox( + __( 'Privacy', 'procorewp' ), + 'suppress_emails', + (bool) $settings['suppress_emails'], + __( 'Never output email addresses on the front end', 'procorewp' ), + __( 'Recommended. Procore project directories contain personal data; publishing them exposes staff and subcontractors to harvesting. When this is off, addresses are still obfuscated and each shortcode must additionally opt in with show_email="true".', 'procorewp' ) + ); + + $this->text( + __( 'Date format', 'procorewp' ), + 'date_format', + (string) $settings['date_format'], + __( 'Leave blank to use the site date format.', 'procorewp' ), + (string) get_option( 'date_format', 'F j, Y' ) + ); + + $this->text( + __( 'Currency symbol', 'procorewp' ), + 'currency_symbol', + (string) $settings['currency_symbol'], + __( 'Prefixed to monetary values such as change order totals.', 'procorewp' ), + '$' + ); + + $this->checkbox( + __( 'Stylesheet', 'procorewp' ), + 'load_styles', + (bool) $settings['load_styles'], + __( 'Load the ProcoreWP stylesheet', 'procorewp' ), + __( 'The stylesheet loads only on pages that actually contain ProcoreWP output. Turn it off entirely if your theme styles the markup itself.', 'procorewp' ) + ); + + $this->row( + __( 'Custom CSS', 'procorewp' ), + function () use ( $settings ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_textarea( (string) $settings['custom_css'] ) + ); + + echo '

' . esc_html__( 'Added inline after the plugin stylesheet. This survives plugin updates — editing files inside the plugin directory does not.', 'procorewp' ) . '

'; + } + ); + + $this->row( + __( 'Template overrides', 'procorewp' ), + function () { + echo '

' . esc_html__( 'To change the markup, copy a file from the plugin\'s templates directory into your theme:', 'procorewp' ) . '

'; + printf( + '

%s%s

', + esc_html( 'wp-content/plugins/procorewp/templates/collection.php' ), + esc_html( 'wp-content/themes/' . get_stylesheet() . '/procorewp/collection.php' ) + ); + echo '

' . esc_html__( 'Available templates: collection, record, field, image, map.', 'procorewp' ) . '

'; + } + ); + } + + /** + * Print the Cache tab fields. + * + * @return void + */ + private function fields_cache(): void { + $settings = Settings::all(); + + $this->checkbox( + __( 'Response cache', 'procorewp' ), + 'enable_cache', + (bool) $settings['enable_cache'], + __( 'Cache Procore responses', 'procorewp' ), + __( 'Strongly recommended. Procore enforces both an hourly and a ten-second rate limit; an uncached page will exhaust the quota quickly. When caching is on, a Procore outage serves the last good response instead of an error.', 'procorewp' ) + ); + + $this->number( + __( 'Minimum cache lifetime', 'procorewp' ), + 'cache_floor', + (int) $settings['cache_floor'], + 30, + DAY_IN_SECONDS, + __( 'Seconds. Shortcodes may request a longer lifetime but never a shorter one, so a single page cannot be configured into a rate-limit breach.', 'procorewp' ) + ); + + $this->number( + __( 'Records per request', 'procorewp' ), + 'per_page', + (int) $settings['per_page'], + 1, + 2000, + __( 'Procore recommends keeping this at or below 2000.', 'procorewp' ) + ); + + $this->number( + __( 'Request timeout', 'procorewp' ), + 'request_timeout', + (int) $settings['request_timeout'], + 5, + 60, + __( 'Seconds to wait for Procore before giving up.', 'procorewp' ) + ); + + $this->row( + __( 'Cached responses', 'procorewp' ), + function () { + $stats = Cache::stats(); + + printf( + '

%s

', + esc_html( + sprintf( + /* translators: %d: number of cached responses. */ + _n( '%d cached response.', '%d cached responses.', (int) $stats['total'], 'procorewp' ), + (int) $stats['total'] + ) + ) + ); + + printf( + '

', + esc_html__( 'Clear cache now', 'procorewp' ) + ); + } + ); + } + + /** + * Print the Tools tab fields. + * + * @return void + */ + private function fields_tools(): void { + $settings = Settings::all(); + + $this->checkbox( + __( 'REST proxy', 'procorewp' ), + 'enable_rest', + (bool) $settings['enable_rest'], + __( 'Expose cached Procore data at /wp-json/procorewp/v1/', 'procorewp' ), + __( 'Lets themes and JavaScript read Procore data without ever seeing your credentials. Only endpoints published in the registry are reachable.', 'procorewp' ) + ); + + $this->row( + __( 'REST access', 'procorewp' ), + function () use ( $settings ) { + $levels = array( + 'public' => __( 'Anyone', 'procorewp' ), + 'logged_in' => __( 'Logged-in users', 'procorewp' ), + 'editor' => __( 'Users who can edit posts', 'procorewp' ), + ); + + printf( ''; + } + ); + + $this->checkbox( + __( 'Block editor', 'procorewp' ), + 'enable_blocks', + (bool) $settings['enable_blocks'], + __( 'Register ProcoreWP blocks', 'procorewp' ), + __( 'Adds a block for each shortcode with a live preview. Shortcodes keep working either way.', 'procorewp' ) + ); + + $this->checkbox( + __( 'Diagnostics', 'procorewp' ), + 'enable_logging', + (bool) $settings['enable_logging'], + __( 'Record API diagnostics', 'procorewp' ), + __( 'Keeps the last 50 events for the Status tab. Credentials and tokens are redacted before anything is written.', 'procorewp' ) + ); + + $this->checkbox( + __( 'Uninstall', 'procorewp' ), + 'keep_data', + (bool) $settings['keep_data'], + __( 'Keep settings and cached data when the plugin is deleted', 'procorewp' ), + __( 'Off by default, so deleting the plugin removes its credentials, tokens and cache.', 'procorewp' ) + ); + } + + /** + * Render the Shortcodes reference tab. + * + * @return void + */ + private function render_shortcodes(): void { + echo '

' . esc_html__( 'Every shortcode below accepts the shared attributes company_id, project_id, limit, page, orderby, order, columns, template, class, title, cache and empty_text. The legacy id attribute is still accepted as an alias for project_id.', 'procorewp' ) . '

'; + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + foreach ( Registrar::definitions() as $tag => $definition ) { + $endpoint = Endpoints::get( (string) $definition['endpoint'] ); + $permission = null !== $endpoint ? (string) $endpoint['permission'] : __( 'Depends on the endpoint attribute', 'procorewp' ); + + echo ''; + printf( + '', + esc_html( $tag ) + ); + printf( '', esc_html( (string) $definition['description'] ) ); + printf( '', esc_html( $permission ) ); + echo ''; + } + + echo '
' . esc_html__( 'Shortcode', 'procorewp' ) . '' . esc_html__( 'What it shows', 'procorewp' ) . '' . esc_html__( 'Procore permission needed', 'procorewp' ) . '
[%1$s]%s%s
'; + + echo '

' . esc_html__( 'Available endpoints for [procore_data]', 'procorewp' ) . '

'; + echo '

' . esc_html( implode( ', ', array_keys( Endpoints::public_endpoints() ) ) ) . '

'; + } + + /** + * Render the Status tab. + * + * @return void + */ + private function render_status(): void { + $tokens = TokenStore::all(); + $limit = Client::rate_limit(); + $circuit = Client::circuit_state(); + + $rows = array( + __( 'Plugin version', 'procorewp' ) => PROCOREWP_VERSION, + __( 'WordPress', 'procorewp' ) => get_bloginfo( 'version' ), + __( 'PHP', 'procorewp' ) => PHP_VERSION, + __( 'Environment', 'procorewp' ) => Environment::choices()[ Environment::current() ], + __( 'API host', 'procorewp' ) => Environment::api_host(), + __( 'Login host', 'procorewp' ) => Environment::login_host(), + __( 'Redirect URI', 'procorewp' ) => Environment::redirect_uri(), + __( 'Authentication', 'procorewp' ) => Client::instance()->auth()->label(), + __( 'Credential storage', 'procorewp' ) => Settings::credentials_are_constants() + ? __( 'wp-config.php constants', 'procorewp' ) + : ( Encryption::is_strong() ? __( 'Encrypted (AES-256-GCM)', 'procorewp' ) : __( 'Unencrypted — OpenSSL unavailable', 'procorewp' ) ), + __( 'Token expires', 'procorewp' ) => $tokens['expires_at'] > 0 + ? wp_date( 'Y-m-d H:i:s', $tokens['expires_at'] ) + : __( 'No token stored', 'procorewp' ), + __( 'Rate limit', 'procorewp' ) => $limit['limit'] > 0 + ? sprintf( '%d / %d', $limit['remaining'], $limit['limit'] ) + : __( 'Not reported yet', 'procorewp' ), + __( 'Circuit breaker', 'procorewp' ) => $circuit['open_until'] > time() + ? sprintf( + /* translators: %s: human readable time difference. */ + __( 'Open — resumes in %s', 'procorewp' ), + human_time_diff( time(), $circuit['open_until'] ) + ) + : __( 'Closed', 'procorewp' ), + __( 'Cached responses', 'procorewp' ) => (string) Cache::stats()['total'], + __( 'Object cache', 'procorewp' ) => wp_using_ext_object_cache() ? __( 'Persistent', 'procorewp' ) : __( 'Database transients', 'procorewp' ), + ); + + echo ''; + + foreach ( $rows as $label => $value ) { + printf( '', esc_html( (string) $label ), esc_html( (string) $value ) ); + } + + echo '
%1$s%2$s
'; + + $entries = Logger::entries(); + + echo '

' . esc_html__( 'Recent diagnostics', 'procorewp' ) . '

'; + + if ( empty( $entries ) ) { + echo '

' . esc_html__( 'Nothing recorded. Turn on diagnostics under Tools to start collecting events.', 'procorewp' ) . '

'; + + return; + } + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + + foreach ( $entries as $entry ) { + printf( + '', + esc_html( wp_date( 'Y-m-d H:i:s', (int) $entry['time'] ) ), + esc_html( (string) $entry['level'] ), + esc_html( (string) $entry['message'] ) + ); + } + + echo '
' . esc_html__( 'When', 'procorewp' ) . '' . esc_html__( 'Level', 'procorewp' ) . '' . esc_html__( 'Event', 'procorewp' ) . '
%1$s%2$s%3$s
'; + } + + /** + * Print a settings table row. + * + * @param string $label Row label. + * @param callable $control Callback that prints the control markup. + * @return void + */ + private function row( string $label, callable $control ): void { + echo '' . esc_html( $label ) . ''; + call_user_func( $control ); + echo ''; + } + + /** + * Print a checkbox row. + * + * @param string $label Row label. + * @param string $key Setting key. + * @param bool $checked Current value. + * @param string $control_text Checkbox label. + * @param string $description Help text. + * @return void + */ + private function checkbox( string $label, string $key, bool $checked, string $control_text, string $description = '' ): void { + $this->row( + $label, + function () use ( $key, $checked, $control_text, $description ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $key ), + checked( $checked, true, false ), + esc_html( $control_text ) + ); + + if ( '' !== $description ) { + echo '

' . esc_html( $description ) . '

'; + } + } + ); + } + + /** + * Print a text input row. + * + * @param string $label Row label. + * @param string $key Setting key. + * @param string $value Current value. + * @param string $description Help text. + * @param string $placeholder Placeholder text. + * @return void + */ + private function text( string $label, string $key, string $value, string $description = '', string $placeholder = '' ): void { + $this->row( + $label, + function () use ( $key, $value, $description, $placeholder ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $key ), + esc_attr( $value ), + esc_attr( $placeholder ) + ); + + if ( '' !== $description ) { + echo '

' . esc_html( $description ) . '

'; + } + } + ); + } + + /** + * Print a number input row. + * + * @param string $label Row label. + * @param string $key Setting key. + * @param int $value Current value. + * @param int $min Minimum accepted value. + * @param int $max Maximum accepted value. + * @param string $description Help text. + * @return void + */ + private function number( string $label, string $key, int $value, int $min, int $max, string $description = '' ): void { + $this->row( + $label, + function () use ( $key, $value, $min, $max, $description ) { + printf( + '', + esc_attr( Settings::OPTION ), + esc_attr( $key ), + esc_attr( (string) $value ), + esc_attr( (string) $min ), + esc_attr( (string) $max ) + ); + + if ( '' !== $description ) { + echo '

' . esc_html( $description ) . '

'; + } + } + ); + } +} From 3ca0ee55f422a69a47abdc0f21284f9764f8e69a Mon Sep 17 00:00:00 2001 From: "Matthew M. Emma" Date: Fri, 7 Aug 2026 10:42:45 -0700 Subject: [PATCH 5/9] Add blocks, an optional REST proxy and WP-CLI commands One block type with a variation per shortcode, so the inserter is populated without eighteen near-identical block types. It renders server-side by delegating to the same shortcode handler, so markup, escaping and caching are identical in both editing surfaces and there is no second rendering path to keep in sync. The editor script is hand-written ES5 against the global wp.* runtime. No bundler, no node_modules. Shipping a minified bundle without its source is a wordpress.org review flag, makes the plugin unauditable and puts Node between a contributor and a one-line fix. The REST proxy at /wp-json/procorewp/v1/ is off by default and read-only. It serves from the same cache the shortcodes use, so exposing it cannot blow the rate limit, and resolves only against endpoints marked public in the registry, so identity endpoints such as /me and /companies return 404. Credentials never reach the browser, and error detail is restricted to administrators. WP-CLI: test, doctor, projects, cache-clear, cache-warm, reset-token. `test` exits non-zero on failure so it works as a deploy gate, and `doctor` reports whether credentials are set without printing them. Co-Authored-By: Claude Opus 5 --- blocks/procore/block.json | 35 +++++ blocks/procore/index.js | 219 ++++++++++++++++++++++++++++ src/Blocks/Registrar.php | 160 +++++++++++++++++++++ src/Cli/Commands.php | 294 ++++++++++++++++++++++++++++++++++++++ src/Rest/Controller.php | 211 +++++++++++++++++++++++++++ 5 files changed, 919 insertions(+) create mode 100644 blocks/procore/block.json create mode 100644 blocks/procore/index.js create mode 100644 src/Blocks/Registrar.php create mode 100644 src/Cli/Commands.php create mode 100644 src/Rest/Controller.php diff --git a/blocks/procore/block.json b/blocks/procore/block.json new file mode 100644 index 0000000..110d689 --- /dev/null +++ b/blocks/procore/block.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "procorewp/procore", + "title": "Procore", + "category": "widgets", + "icon": "building", + "description": "Display data from Procore. Pick what to show, then set the project or company it applies to.", + "keywords": [ "procore", "construction", "project", "rfi", "submittal" ], + "textdomain": "procorewp", + "supports": { + "html": false, + "align": [ "wide", "full" ], + "spacing": { + "margin": true, + "padding": true + }, + "color": { + "background": true, + "text": true + } + }, + "attributes": { + "shortcode": { + "type": "string", + "default": "procore_project_list" + }, + "atts": { + "type": "object", + "default": {} + } + }, + "editorScript": "file:./index.js", + "style": "procorewp" +} diff --git a/blocks/procore/index.js b/blocks/procore/index.js new file mode 100644 index 0000000..118c9f9 --- /dev/null +++ b/blocks/procore/index.js @@ -0,0 +1,219 @@ +/** + * ProcoreWP block editor integration. + * + * Hand-written ES5 against the global `wp.*` runtime, with no build step, so + * the file that ships is the file that was authored. + * + * @package ProcoreWP + */ + +( function ( wp, config ) { + 'use strict'; + + if ( ! wp || ! wp.blocks || ! wp.element ) { + return; + } + + var el = wp.element.createElement; + var Fragment = wp.element.Fragment; + var __ = wp.i18n.__; + var InspectorControls = wp.blockEditor.InspectorControls; + var useBlockProps = wp.blockEditor.useBlockProps; + var PanelBody = wp.components.PanelBody; + var SelectControl = wp.components.SelectControl; + var TextControl = wp.components.TextControl; + var ToggleControl = wp.components.ToggleControl; + var Placeholder = wp.components.Placeholder; + var ServerSideRender = wp.serverSideRender; + + var variations = ( config && config.variations ) || []; + + /** + * Attributes surfaced as controls, in display order. + * + * Everything else remains reachable through the equivalent shortcode. + */ + var CONTROLS = [ + { key: 'project_id', label: __( 'Project ID', 'procorewp' ), type: 'number' }, + { key: 'company_id', label: __( 'Company ID', 'procorewp' ), type: 'number' }, + { key: 'endpoint', label: __( 'Endpoint', 'procorewp' ), type: 'text' }, + { key: 'field', label: __( 'Field', 'procorewp' ), type: 'text' }, + { key: 'label', label: __( 'Label', 'procorewp' ), type: 'text' }, + { key: 'title', label: __( 'Heading', 'procorewp' ), type: 'text' }, + { key: 'limit', label: __( 'Maximum rows', 'procorewp' ), type: 'number' }, + { key: 'status', label: __( 'Filter by status', 'procorewp' ), type: 'text' }, + { key: 'orderby', label: __( 'Sort by field', 'procorewp' ), type: 'text' }, + { key: 'order', label: __( 'Sort direction', 'procorewp' ), type: 'select', options: [ + { label: __( 'Ascending', 'procorewp' ), value: 'asc' }, + { label: __( 'Descending', 'procorewp' ), value: 'desc' }, + ] }, + { key: 'columns', label: __( 'Columns', 'procorewp' ), type: 'text' }, + { key: 'width', label: __( 'Image width', 'procorewp' ), type: 'number' }, + { key: 'show_email', label: __( 'Show email addresses', 'procorewp' ), type: 'toggle' }, + { key: 'all', label: __( 'Fetch every page', 'procorewp' ), type: 'toggle' }, + { key: 'class', label: __( 'Extra CSS classes', 'procorewp' ), type: 'text' }, + ]; + + /** + * Find the variation definition for a shortcode tag. + * + * @param {string} shortcode Shortcode tag. + * @return {Object|null} Variation, or null. + */ + function findVariation( shortcode ) { + for ( var i = 0; i < variations.length; i++ ) { + if ( variations[ i ].shortcode === shortcode ) { + return variations[ i ]; + } + } + + return null; + } + + /** + * Build the inspector controls for the selected shortcode. + * + * @param {Object} attributes Block attributes. + * @param {Function} setAttributes Attribute setter. + * @return {Array} Control elements. + */ + function buildControls( attributes, setAttributes ) { + var variation = findVariation( attributes.shortcode ); + var supported = variation ? variation.atts : []; + var atts = attributes.atts || {}; + + /** + * Persist one attribute. + * + * @param {string} key Attribute name. + * @param {*} value New value. + */ + function update( key, value ) { + var next = Object.assign( {}, atts ); + + if ( '' === value || false === value || null === value || undefined === value ) { + delete next[ key ]; + } else { + next[ key ] = String( value ); + } + + setAttributes( { atts: next } ); + } + + return CONTROLS.filter( function ( control ) { + return -1 !== supported.indexOf( control.key ); + } ).map( function ( control ) { + var value = undefined === atts[ control.key ] ? '' : atts[ control.key ]; + + if ( 'toggle' === control.type ) { + return el( ToggleControl, { + key: control.key, + label: control.label, + checked: 'true' === value, + onChange: function ( next ) { + update( control.key, next ? 'true' : '' ); + }, + __nextHasNoMarginBottom: true, + } ); + } + + if ( 'select' === control.type ) { + return el( SelectControl, { + key: control.key, + label: control.label, + value: value, + options: [ { label: __( 'Default', 'procorewp' ), value: '' } ].concat( control.options ), + onChange: function ( next ) { + update( control.key, next ); + }, + __nextHasNoMarginBottom: true, + } ); + } + + return el( TextControl, { + key: control.key, + label: control.label, + type: 'number' === control.type ? 'number' : 'text', + value: value, + onChange: function ( next ) { + update( control.key, next ); + }, + __nextHasNoMarginBottom: true, + } ); + } ); + } + + wp.blocks.registerBlockType( 'procorewp/procore', { + edit: function ( props ) { + var attributes = props.attributes; + var setAttributes = props.setAttributes; + var blockProps = useBlockProps ? useBlockProps() : {}; + var variation = findVariation( attributes.shortcode ); + + var inspector = el( + InspectorControls, + {}, + el( + PanelBody, + { title: __( 'Procore data', 'procorewp' ), initialOpen: true }, + el( SelectControl, { + label: __( 'Show', 'procorewp' ), + value: attributes.shortcode, + options: variations.map( function ( item ) { + return { label: item.title, value: item.shortcode }; + } ), + onChange: function ( next ) { + setAttributes( { shortcode: next } ); + }, + help: variation ? variation.description : '', + __nextHasNoMarginBottom: true, + } ) + ), + el( + PanelBody, + { title: __( 'Options', 'procorewp' ), initialOpen: true }, + buildControls( attributes, setAttributes ) + ) + ); + + var preview = ServerSideRender + ? el( ServerSideRender, { + block: 'procorewp/procore', + attributes: attributes, + EmptyResponsePlaceholder: function () { + return el( + Placeholder, + { icon: 'building', label: __( 'Procore', 'procorewp' ) }, + __( 'Nothing to display yet. Check the project and company IDs in the block settings.', 'procorewp' ) + ); + }, + } ) + : el( + Placeholder, + { icon: 'building', label: __( 'Procore', 'procorewp' ) }, + variation ? variation.title : attributes.shortcode + ); + + return el( Fragment, {}, inspector, el( 'div', blockProps, preview ) ); + }, + + save: function () { + // Rendered server-side so cached data and escaping stay on the server. + return null; + }, + + variations: variations.map( function ( item ) { + return { + name: item.name, + title: item.title, + description: item.description, + icon: 'building', + attributes: { shortcode: item.shortcode }, + scope: [ 'inserter' ], + isActive: function ( blockAttributes ) { + return blockAttributes.shortcode === item.shortcode; + }, + }; + } ), + } ); +} )( window.wp, window.procorewpBlocks ); diff --git a/src/Blocks/Registrar.php b/src/Blocks/Registrar.php new file mode 100644 index 0000000..391598d --- /dev/null +++ b/src/Blocks/Registrar.php @@ -0,0 +1,160 @@ + array( $this, 'render' ), + ) + ); + } + + /** + * Render the block by delegating to the matching shortcode. + * + * @param array $attributes Block attributes. + * @return string Rendered markup. + */ + public function render( array $attributes ): string { + $tag = isset( $attributes['shortcode'] ) ? sanitize_key( (string) $attributes['shortcode'] ) : ''; + + if ( '' === $tag || null === ShortcodeRegistrar::get( $tag ) ) { + return ''; + } + + $atts = array(); + $given = isset( $attributes['atts'] ) && is_array( $attributes['atts'] ) ? $attributes['atts'] : array(); + + foreach ( $given as $key => $value ) { + if ( '' === $value || null === $value ) { + continue; + } + + $atts[] = sprintf( '%s="%s"', sanitize_key( (string) $key ), esc_attr( (string) $value ) ); + } + + $shortcode = sprintf( '[%s %s]', $tag, implode( ' ', $atts ) ); + + return do_shortcode( $shortcode ); + } + + /** + * Expose the shortcode registry to the editor script. + * + * @return void + */ + public function localize(): void { + if ( ! Settings::get( 'enable_blocks', true ) ) { + return; + } + + $handle = generate_block_asset_handle( self::BLOCK, 'editorScript' ); + + if ( ! wp_script_is( $handle, 'registered' ) ) { + return; + } + + $variations = array(); + + foreach ( ShortcodeRegistrar::definitions() as $tag => $definition ) { + $handler = new $definition['handler']( $definition ); + + $variations[] = array( + 'name' => str_replace( 'procore_', '', $tag ), + 'shortcode' => $tag, + 'title' => $this->variation_title( $tag, $definition ), + 'description' => (string) $definition['description'], + 'atts' => array_keys( $handler->defaults() ), + ); + } + + wp_localize_script( + $handle, + 'procorewpBlocks', + array( + 'variations' => $variations, + 'restUrl' => rest_url( 'procorewp/v1' ), + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( \ProcoreWP\Admin\Ajax::NONCE ), + ) + ); + + wp_set_script_translations( $handle, 'procorewp', PROCOREWP_PATH . 'languages' ); + } + + /** + * Build a human readable variation title. + * + * @param string $tag Shortcode tag. + * @param array $definition Shortcode definition. + * @return string Title. + */ + private function variation_title( string $tag, array $definition ): string { + if ( '' !== (string) ( $definition['title'] ?? '' ) ) { + return sprintf( + /* translators: %s: shortcode display name, e.g. "RFIs". */ + __( 'Procore: %s', 'procorewp' ), + (string) $definition['title'] + ); + } + + return sprintf( + /* translators: %s: shortcode display name. */ + __( 'Procore: %s', 'procorewp' ), + ucwords( str_replace( array( 'procore_', '_' ), array( '', ' ' ), $tag ) ) + ); + } +} diff --git a/src/Cli/Commands.php b/src/Cli/Commands.php new file mode 100644 index 0000000..8f81b5f --- /dev/null +++ b/src/Cli/Commands.php @@ -0,0 +1,294 @@ + $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function test( array $args, array $assoc_args ): void { + $report = ( new ConnectionTester() )->run(); + + $rows = array(); + + foreach ( $report['steps'] as $step ) { + $rows[] = array( + 'check' => $step['label'], + 'result' => $step['ok'] ? 'OK' : 'FAIL', + 'detail' => $step['message'], + ); + } + + \WP_CLI\Utils\format_items( 'table', $rows, array( 'check', 'result', 'detail' ) ); + + if ( ! empty( $report['probes'] ) ) { + \WP_CLI::line( '' ); + \WP_CLI::line( 'Endpoint permissions:' ); + \WP_CLI\Utils\format_items( 'table', $report['probes'], array( 'slug', 'status', 'message', 'permission' ) ); + } + + if ( $report['ok'] ) { + \WP_CLI::success( $report['summary'] ); + + return; + } + + \WP_CLI::warning( $report['summary'] ); + } + + /** + * Purge cached Procore responses. + * + * ## OPTIONS + * + * [--group=] + * : Only purge one endpoint group, e.g. projects. + * + * ## EXAMPLES + * + * wp procorewp cache-clear + * wp procorewp cache-clear --group=rfis + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function cache_clear( array $args, array $assoc_args ): void { + $group = isset( $assoc_args['group'] ) ? sanitize_key( $assoc_args['group'] ) : ''; + $removed = Cache::flush( $group ); + + \WP_CLI::success( + sprintf( + /* translators: %d: number of cache entries removed. */ + _n( 'Removed %d cached response.', 'Removed %d cached responses.', $removed, 'procorewp' ), + $removed + ) + ); + } + + /** + * Refresh cached project data for the default company. + * + * ## EXAMPLES + * + * wp procorewp cache-warm + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function cache_warm( array $args, array $assoc_args ): void { + $company = Settings::default_company_id(); + + if ( $company <= 0 ) { + \WP_CLI::error( 'No default company is configured.' ); + } + + $projects = Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'all' => true, + 'bypass_cache' => true, + ) + ); + + if ( is_wp_error( $projects ) ) { + \WP_CLI::error( $projects->get_error_message() ); + } + + \WP_CLI::success( sprintf( 'Cached %d projects.', is_array( $projects ) ? count( $projects ) : 0 ) ); + } + + /** + * List the projects visible to the configured credentials. + * + * ## OPTIONS + * + * [--company=] + * : Company ID. Defaults to the configured company. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - csv + * - json + * - count + * --- + * + * ## EXAMPLES + * + * wp procorewp projects --format=csv + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function projects( array $args, array $assoc_args ): void { + $company = isset( $assoc_args['company'] ) ? absint( $assoc_args['company'] ) : Settings::default_company_id(); + + $projects = Client::instance()->fetch( + 'projects', + array(), + array( + 'company_id' => $company, + 'all' => true, + ) + ); + + if ( is_wp_error( $projects ) ) { + \WP_CLI::error( $projects->get_error_message() ); + } + + $rows = array(); + + foreach ( (array) $projects as $project ) { + $rows[] = array( + 'id' => absint( Arr::get( $project, 'id', 0 ) ), + 'name' => Arr::str( $project, 'name' ), + 'number' => Arr::str( $project, 'project_number' ), + 'location' => \ProcoreWP\Support\Format::location( $project ), + 'active' => Arr::get( $project, 'active' ) ? 'yes' : 'no', + ); + } + + \WP_CLI\Utils\format_items( + isset( $assoc_args['format'] ) ? (string) $assoc_args['format'] : 'table', + $rows, + array( 'id', 'name', 'number', 'location', 'active' ) + ); + } + + /** + * Discard the stored token so the next request re-authenticates. + * + * ## EXAMPLES + * + * wp procorewp reset-token + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function reset_token( array $args, array $assoc_args ): void { + TokenStore::clear(); + Client::reset_circuit(); + + \WP_CLI::success( 'Stored tokens cleared.' ); + } + + /** + * Print a summary of the current configuration. + * + * ## EXAMPLES + * + * wp procorewp doctor + * + * @param array $args Positional arguments. + * @param array $assoc_args Associative arguments. + * @return void + */ + public function doctor( array $args, array $assoc_args ): void { + $limit = Client::rate_limit(); + $circuit = Client::circuit_state(); + + $rows = array( + array( + 'setting' => 'Plugin version', + 'value' => PROCOREWP_VERSION, + ), + array( + 'setting' => 'Environment', + 'value' => Environment::current(), + ), + array( + 'setting' => 'API host', + 'value' => Environment::api_host(), + ), + array( + 'setting' => 'Login host', + 'value' => Environment::login_host(), + ), + array( + 'setting' => 'Redirect URI', + 'value' => Environment::redirect_uri(), + ), + array( + 'setting' => 'Auth mode', + 'value' => (string) Settings::get( 'auth_mode', '' ), + ), + array( + 'setting' => 'Client ID set', + 'value' => '' !== Settings::client_id() ? 'yes' : 'no', + ), + array( + 'setting' => 'Client secret set', + 'value' => '' !== Settings::client_secret() ? 'yes' : 'no', + ), + array( + 'setting' => 'Default company', + 'value' => (string) Settings::default_company_id(), + ), + array( + 'setting' => 'Cache enabled', + 'value' => Cache::enabled() ? 'yes' : 'no', + ), + array( + 'setting' => 'Cached responses', + 'value' => (string) Cache::stats()['total'], + ), + array( + 'setting' => 'Rate limit', + 'value' => $limit['limit'] > 0 ? $limit['remaining'] . '/' . $limit['limit'] : 'unknown', + ), + array( + 'setting' => 'Circuit breaker', + 'value' => $circuit['open_until'] > time() ? 'open' : 'closed', + ), + ); + + \WP_CLI\Utils\format_items( 'table', $rows, array( 'setting', 'value' ) ); + } +} diff --git a/src/Rest/Controller.php b/src/Rest/Controller.php new file mode 100644 index 0000000..416d685 --- /dev/null +++ b/src/Rest/Controller.php @@ -0,0 +1,211 @@ + \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_endpoints' ), + 'permission_callback' => array( $this, 'check_permission' ), + ) + ); + + register_rest_route( + self::NAMESPACE, + '/data/(?P[a-z0-9_]+)', + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_data' ), + 'permission_callback' => array( $this, 'check_permission' ), + 'args' => array( + 'endpoint' => array( + 'required' => true, + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'validate_callback' => static function ( $value ): bool { + return array_key_exists( (string) $value, Endpoints::public_endpoints() ); + }, + ), + 'company_id' => array( + 'type' => 'integer', + 'default' => 0, + 'sanitize_callback' => 'absint', + ), + 'project_id' => array( + 'type' => 'integer', + 'default' => 0, + 'sanitize_callback' => 'absint', + ), + 'per_page' => array( + 'type' => 'integer', + 'default' => 100, + 'minimum' => 1, + 'maximum' => 2000, + 'sanitize_callback' => 'absint', + ), + 'page' => array( + 'type' => 'integer', + 'default' => 1, + 'minimum' => 1, + 'sanitize_callback' => 'absint', + ), + ), + ) + ); + } + + /** + * List the endpoints this proxy will serve. + * + * @return \WP_REST_Response Endpoint metadata. + */ + public function get_endpoints(): \WP_REST_Response { + $endpoints = array(); + + foreach ( Endpoints::public_endpoints() as $slug => $definition ) { + $endpoints[] = array( + 'slug' => $slug, + 'label' => (string) $definition['label'], + 'scope' => (string) $definition['scope'], + 'fields' => (array) $definition['fields'], + ); + } + + return rest_ensure_response( array( 'endpoints' => $endpoints ) ); + } + + /** + * Serve a cached endpoint payload. + * + * @param \WP_REST_Request $request Incoming request. + * @return \WP_REST_Response|\WP_Error Response payload, or an error. + */ + public function get_data( \WP_REST_Request $request ) { + $client = Client::instance(); + + $data = $client->fetch( + (string) $request->get_param( 'endpoint' ), + array( 'page' => (int) $request->get_param( 'page' ) ), + array( + 'company_id' => (int) $request->get_param( 'company_id' ), + 'project_id' => (int) $request->get_param( 'project_id' ), + 'per_page' => (int) $request->get_param( 'per_page' ), + ) + ); + + if ( is_wp_error( $data ) ) { + $status = (int) ( $data->get_error_data()['status'] ?? 502 ); + + return new \WP_Error( + $data->get_error_code(), + $this->public_message( $data ), + array( 'status' => $status >= 400 && $status < 600 ? $status : 502 ) + ); + } + + $meta = $client->meta(); + + $response = rest_ensure_response( + array( + 'endpoint' => (string) $request->get_param( 'endpoint' ), + 'cached' => (bool) $meta['cached'], + 'stale' => (bool) $meta['stale'], + 'total' => $meta['total'], + 'data' => $data, + ) + ); + + $response->header( 'X-ProcoreWP-Cached', $meta['cached'] ? '1' : '0' ); + + return $response; + } + + /** + * Whether the current request may use the proxy. + * + * @return bool|\WP_Error True when permitted, or an error. + */ + public function check_permission() { + if ( ! Settings::get( 'enable_rest', false ) ) { + return new \WP_Error( + 'procorewp_rest_disabled', + __( 'The ProcoreWP REST proxy is disabled.', 'procorewp' ), + array( 'status' => 404 ) + ); + } + + $level = (string) Settings::get( 'rest_access', 'logged_in' ); + + if ( 'public' === $level ) { + return true; + } + + if ( 'editor' === $level ) { + return current_user_can( 'edit_posts' ); + } + + return is_user_logged_in(); + } + + /** + * Reduce an internal error to something safe to return over HTTP. + * + * Administrators receive the underlying detail; everyone else does not, + * because Procore error messages routinely name accounts and permissions. + * + * @param \WP_Error $error Internal error. + * @return string Message. + */ + private function public_message( \WP_Error $error ): string { + if ( current_user_can( 'manage_options' ) ) { + return $error->get_error_message(); + } + + return __( 'Project information is temporarily unavailable.', 'procorewp' ); + } +} From d3604f80f79f9eee4aaecf23c167c31d7c587324 Mon Sep 17 00:00:00 2001 From: "Matthew M. Emma" Date: Fri, 7 Aug 2026 10:42:57 -0700 Subject: [PATCH 6/9] Add the test suite and the translation template 59 tests, 270 assertions, run against JSON fixtures through an injected HTTP transport, so no Procore credentials and no network access are needed. WordPress is shimmed to only the functions the code under test actually calls. Coverage is aimed at the things that broke in 1.x and the things that are easy to regress: the Procore-Company-Id header being present, requests reaching the API host and never the login host, Link-header pagination including refusing off-host links, 429 and 503 backoff, cache hits and the stale fallback, the circuit breaker, encryption round-trips and secret masking, endpoint allow-list refusals, settings sanitization, legacy attribute aliases, and every shortcode's output escaping and email suppression. Two of these tests found real bugs while being written: sort_by() promoted records with no value to the top when reversed, and the class sanitizer needed asserting on what it actually guarantees rather than on a specific output string. Both are fixed in the code they cover. languages/procorewp.pot carries 293 strings with their translator comments. 1.x declared a text domain and contained no translation calls at all. Co-Authored-By: Claude Opus 5 --- languages/procorewp.pot | 1275 ++++++++++++++++++++++++++++++++++ tests/bootstrap.php | 45 ++ tests/unit/ClientTest.php | 302 ++++++++ tests/unit/EndpointsTest.php | 92 +++ tests/unit/FakeAuth.php | 71 ++ tests/unit/SettingsTest.php | 163 +++++ tests/unit/ShortcodeTest.php | 541 +++++++++++++++ tests/unit/SupportTest.php | 246 +++++++ tests/unit/TestCase.php | 90 +++ tests/wp-shims.php | 381 ++++++++++ 10 files changed, 3206 insertions(+) create mode 100644 languages/procorewp.pot create mode 100644 tests/bootstrap.php create mode 100644 tests/unit/ClientTest.php create mode 100644 tests/unit/EndpointsTest.php create mode 100644 tests/unit/FakeAuth.php create mode 100644 tests/unit/SettingsTest.php create mode 100644 tests/unit/ShortcodeTest.php create mode 100644 tests/unit/SupportTest.php create mode 100644 tests/unit/TestCase.php create mode 100644 tests/wp-shims.php diff --git a/languages/procorewp.pot b/languages/procorewp.pot new file mode 100644 index 0000000..723e0a1 --- /dev/null +++ b/languages/procorewp.pot @@ -0,0 +1,1275 @@ +# Copyright (C) 2026 ibuilder +# This file is distributed under the GPL-2.0-or-later license. +msgid "" +msgstr "" +"Project-Id-Version: ProcoreWP 2.0.0\n" +"Report-Msgid-Bugs-To: https://github.com/ibuilder/ProcoreWP/issues\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"POT-Creation-Date: 2026-08-07T00:00:00+00:00\n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: ProcoreWP build script\n" +"X-Domain: procorewp\n" + +#. translators: 1: number of companies, 2: comma-separated company names. +#: src/Admin/ConnectionTester.php:193 +msgid "%1$d company available: %2$s" +msgid_plural "%1$d companies available: %2$s" +msgstr[0] "" +msgstr[1] "" + +#. translators: 1: remaining requests, 2: total allowed requests, 3: human readable time until reset. +#: src/Admin/ConnectionTester.php:269 +msgid "%1$d of %2$d requests remaining; resets in %3$s." +msgstr "" + +#. translators: 1: number of projects, 2: company ID. +#: src/Admin/ConnectionTester.php:243 +msgid "%1$d project visible in company %2$d." +msgid_plural "%1$d projects visible in company %2$d." +msgstr[0] "" +msgstr[1] "" + +#. translators: %d: number of cached responses. +#: src/Admin/SettingsPage.php:528 +msgid "%d cached response." +msgid_plural "%d cached responses." +msgstr[0] "" +msgstr[1] "" + +#. translators: %d: number of failed checks. +#: src/Admin/ConnectionTester.php:421 +msgid "%d check needs attention." +msgid_plural "%d checks need attention." +msgstr[0] "" +msgstr[1] "" + +#. translators: %s: project name. +#: src/Frontend/Shortcodes/ImageShortcode.php:76 +msgid "%s project image" +msgstr "" + +#: src/Api/Endpoints.php:341 +msgid "A Procore company ID is required for this request." +msgstr "" + +#: src/Api/Endpoints.php:349 +#: src/Api/Endpoints.php:356 +msgid "A Procore project ID is required for this request." +msgstr "" + +#: src/Admin/SettingsPage.php:293 +msgid "A service account keeps working when staff change and is installed by a Procore company administrator from the App Marketplace. A user connection inherits one person's permissions and breaks if their access is revoked." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:234 +msgid "A single allow-listed field from a project record." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:67 +msgid "A table of Procore projects for a company." +msgstr "" + +#: src/Admin/SettingsPage.php:656 +msgid "API host" +msgstr "" + +#: src/Admin/ConnectionTester.php:109 +#: src/Admin/ConnectionTester.php:115 +#: src/Admin/ConnectionTester.php:122 +msgid "Access token" +msgstr "" + +#: src/Support/Format.php:162 +msgid "Active" +msgstr "" + +#: src/Admin/SettingsPage.php:457 +msgid "Added inline after the plugin stylesheet. This survives plugin updates — editing files inside the plugin directory does not." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:112 +#: src/Frontend/Shortcodes/Registrar.php:488 +msgid "Address" +msgstr "" + +#: src/Admin/SettingsPage.php:587 +msgid "Adds a block for each shortcode with a live preview. Shortcodes keep working either way." +msgstr "" + +#: src/Api/Auth/ClientCredentials.php:45 +msgid "Another request is currently authenticating with Procore. Please try again in a moment." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:67 +msgid "Another request is currently refreshing the Procore connection. Please try again in a moment." +msgstr "" + +#: src/Admin/SettingsPage.php:562 +msgid "Anyone" +msgstr "" + +#: src/Admin/ConnectionTester.php:142 +#: src/Admin/ConnectionTester.php:149 +#: src/Api/Endpoints.php:66 +msgid "Authenticated account" +msgstr "" + +#: src/Admin/SettingsPage.php:276 +#: src/Admin/SettingsPage.php:659 +msgid "Authentication" +msgstr "" + +#: src/Admin/SettingsPage.php:228 +msgid "Authorize this site" +msgstr "" + +#: src/Admin/SettingsPage.php:637 +msgid "Available endpoints for [procore_data]" +msgstr "" + +#: src/Admin/SettingsPage.php:470 +msgid "Available templates: collection, record, field, image, map." +msgstr "" + +#: src/Admin/SettingsPage.php:583 +msgid "Block editor" +msgstr "" + +#: src/Admin/SettingsPage.php:145 +msgid "Cache" +msgstr "" + +#: src/Admin/SettingsPage.php:487 +msgid "Cache Procore responses" +msgstr "" + +#: src/Admin/SettingsPage.php:519 +#: src/Admin/SettingsPage.php:676 +msgid "Cached responses" +msgstr "" + +#: src/Api/Endpoints.php:249 +msgid "Change Orders: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:243 +msgid "Change order packages" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:388 +msgid "Change order packages raised on a project." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:387 +msgid "Change orders" +msgstr "" + +#: src/Admin/SettingsPage.php:669 +msgid "Circuit breaker" +msgstr "" + +#: src/Admin/SettingsPage.php:536 +msgid "Clear cache now" +msgstr "" + +#: src/Admin/SettingsPage.php:114 +msgid "Clearing…" +msgstr "" + +#: src/Admin/SettingsPage.php:341 +msgid "Client ID" +msgstr "" + +#: src/Admin/SettingsPage.php:352 +msgid "Client Secret" +msgstr "" + +#: src/Admin/SettingsPage.php:675 +msgid "Closed" +msgstr "" + +#: src/Api/Endpoints.php:76 +msgid "Companies" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:450 +msgid "Companies in the Procore company directory." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:171 +#: src/Frontend/Shortcodes/Registrar.php:454 +msgid "Company" +msgstr "" + +#: src/Api/Endpoints.php:116 +msgid "Company Admin / Project Directory: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:91 +msgid "Company Admin: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:81 +#: src/Api/Endpoints.php:102 +msgid "Company Directory: Read Only" +msgstr "" + +#: src/Admin/ConnectionTester.php:171 +#: src/Admin/ConnectionTester.php:176 +#: src/Admin/ConnectionTester.php:189 +msgid "Company access" +msgstr "" + +#: src/Api/Endpoints.php:96 +msgid "Company directory (vendors)" +msgstr "" + +#: src/Api/Endpoints.php:86 +msgid "Company offices" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:437 +msgid "Complete" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:142 +msgid "Completion date" +msgstr "" + +#: src/Admin/ConnectionTester.php:86 +msgid "Configuration" +msgstr "" + +#: src/Admin/SettingsPage.php:248 +msgid "Connect to Procore" +msgstr "" + +#. translators: 1: account display name, 2: account login. +#: src/Admin/ConnectionTester.php:154 +msgid "Connected as %1$s (%2$s)." +msgstr "" + +#: src/Admin/Notices.php:68 +msgid "Connected to Procore." +msgstr "" + +#: src/Admin/ConnectionTester.php:158 +msgid "Connected." +msgstr "" + +#: src/Admin/SettingsPage.php:143 +msgid "Connection" +msgstr "" + +#: src/Admin/SettingsPage.php:117 +msgid "Copied" +msgstr "" + +#: src/Admin/SettingsPage.php:234 +msgid "Copy" +msgstr "" + +#: src/Admin/SettingsPage.php:660 +msgid "Credential storage" +msgstr "" + +#: src/Admin/SettingsPage.php:334 +msgid "Credentials" +msgstr "" + +#: src/Admin/ConnectionTester.php:109 +msgid "Credentials are incomplete." +msgstr "" + +#: src/Admin/SettingsPage.php:336 +msgid "Credentials are supplied by PROCOREWP_CLIENT_ID and PROCOREWP_CLIENT_SECRET in wp-config.php and are not stored in the database. Remove those constants to manage credentials here." +msgstr "" + +#: src/Admin/SettingsPage.php:433 +msgid "Currency symbol" +msgstr "" + +#: src/Api/Environment.php:56 +msgid "Custom (regional or federal zone)" +msgstr "" + +#: src/Admin/SettingsPage.php:323 +msgid "Custom API host" +msgstr "" + +#: src/Admin/SettingsPage.php:449 +msgid "Custom CSS" +msgstr "" + +#: src/Admin/SettingsPage.php:316 +msgid "Custom login host" +msgstr "" + +#: src/Api/Endpoints.php:238 +msgid "Daily Log: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:232 +msgid "Daily construction report logs" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:364 +msgid "Daily construction report logs for a project." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:363 +msgid "Daily logs" +msgstr "" + +#: src/Admin/SettingsPage.php:677 +msgid "Database transients" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:368 +msgid "Date" +msgstr "" + +#: src/Admin/SettingsPage.php:425 +msgid "Date format" +msgstr "" + +#: src/Admin/ConnectionTester.php:210 +#: src/Admin/ConnectionTester.php:227 +#: src/Admin/ConnectionTester.php:233 +#: src/Admin/SettingsPage.php:376 +msgid "Default company" +msgstr "" + +#: src/Admin/SettingsPage.php:395 +msgid "Default project" +msgstr "" + +#: src/Admin/SettingsPage.php:366 +msgid "Delete the stored secret" +msgstr "" + +#: src/Admin/SettingsPage.php:623 +msgid "Depends on the endpoint attribute" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:201 +#: src/Frontend/Shortcodes/Registrar.php:220 +#: src/Frontend/Shortcodes/Registrar.php:314 +msgid "Description" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:108 +msgid "Detail panel for a single Procore project." +msgstr "" + +#: src/Api/Environment.php:54 +msgid "Developer Sandbox" +msgstr "" + +#: src/Admin/SettingsPage.php:591 +msgid "Diagnostics" +msgstr "" + +#: src/Admin/SettingsPage.php:242 +msgid "Disconnect from Procore" +msgstr "" + +#: src/Admin/SettingsPage.php:118 +msgid "Disconnect this site from Procore?" +msgstr "" + +#: src/Admin/Ajax.php:160 +msgid "Disconnected from Procore." +msgstr "" + +#: src/Admin/SettingsPage.php:144 +msgid "Display" +msgstr "" + +#: src/Api/Endpoints.php:154 +msgid "Drawing areas" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:192 +msgid "Drawing areas published for a project." +msgstr "" + +#: src/Api/Endpoints.php:165 +msgid "Drawing revisions" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:191 +msgid "Drawings" +msgstr "" + +#: src/Api/Endpoints.php:160 +#: src/Api/Endpoints.php:171 +msgid "Drawings: Read Only" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:264 +#: src/Frontend/Shortcodes/Registrar.php:294 +#: src/Frontend/Shortcodes/Registrar.php:324 +#: src/Frontend/Shortcodes/Registrar.php:354 +msgid "Due" +msgstr "" + +#: src/Admin/SettingsPage.php:312 +msgid "Each environment needs its own credentials; tokens are not shared between them." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:176 +msgid "Email" +msgstr "" + +#: src/Admin/SettingsPage.php:662 +msgid "Encrypted (AES-256-GCM)" +msgstr "" + +#: src/Admin/Notices.php:77 +msgid "Enter a Client ID and Client Secret before connecting." +msgstr "" + +#: src/Api/Auth/AbstractAuth.php:40 +msgid "Enter your Procore Client ID and Client Secret before connecting." +msgstr "" + +#: src/Admin/SettingsPage.php:298 +#: src/Admin/SettingsPage.php:655 +msgid "Environment" +msgstr "" + +#: src/Admin/SettingsPage.php:701 +msgid "Event" +msgstr "" + +#: src/Admin/SettingsPage.php:613 +msgid "Every shortcode below accepts the shared attributes company_id, project_id, limit, page, orderby, order, columns, template, class, title, cache and empty_text. The legacy id attribute is still accepted as an alias for project_id." +msgstr "" + +#: src/Admin/ConnectionTester.php:418 +msgid "Everything checked out." +msgstr "" + +#: src/Admin/SettingsPage.php:554 +msgid "Expose cached Procore data at /wp-json/procorewp/v1/" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:432 +msgid "Finish" +msgstr "" + +#: src/Admin/SettingsPage.php:370 +msgid "For the strongest protection, define PROCOREWP_CLIENT_SECRET in wp-config.php instead of storing it here." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:79 +msgid "ID" +msgstr "" + +#: src/Support/Format.php:162 +msgid "Inactive" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:309 +msgid "Item" +msgstr "" + +#: src/Admin/SettingsPage.php:602 +msgid "Keep settings and cached data when the plugin is deleted" +msgstr "" + +#: src/Admin/SettingsPage.php:595 +msgid "Keeps the last 50 events for the Status tab. Credentials and tokens are redacted before anything is written." +msgstr "" + +#: src/Admin/SettingsPage.php:428 +msgid "Leave blank to use the site date format." +msgstr "" + +#: src/Admin/SettingsPage.php:555 +msgid "Lets themes and JavaScript read Procore data without ever seeing your credentials. Only endpoints published in the registry are reachable." +msgstr "" + +#: src/Admin/SettingsPage.php:700 +msgid "Level" +msgstr "" + +#: src/Admin/SettingsPage.php:444 +msgid "Load the ProcoreWP stylesheet" +msgstr "" + +#: src/Admin/SettingsPage.php:115 +msgid "Loading…" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:94 +#: src/Frontend/Shortcodes/Registrar.php:117 +#: src/Frontend/Shortcodes/Registrar.php:459 +#: src/Frontend/Shortcodes/Registrar.php:493 +msgid "Location" +msgstr "" + +#: src/Admin/SettingsPage.php:563 +msgid "Logged-in users" +msgstr "" + +#: src/Admin/SettingsPage.php:657 +msgid "Login host" +msgstr "" + +#: src/Admin/SettingsPage.php:386 +msgid "Look up companies" +msgstr "" + +#: src/Admin/SettingsPage.php:492 +msgid "Minimum cache lifetime" +msgstr "" + +#: src/Api/Environment.php:55 +msgid "Monthly Sandbox" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:161 +#: src/Frontend/Shortcodes/Registrar.php:196 +msgid "Name" +msgstr "" + +#: src/Admin/SettingsPage.php:420 +msgid "Never output email addresses on the front end" +msgstr "" + +#: src/Support/Arr.php:82 +msgid "No" +msgstr "" + +#: src/Admin/ConnectionTester.php:74 +msgid "No Client ID is set." +msgstr "" + +#: src/Admin/ConnectionTester.php:78 +msgid "No Client Secret is set." +msgstr "" + +#: src/Api/Client.php:182 +msgid "No Procore company ID is available. Set a default company in Procore → Connection, or pass company_id." +msgstr "" + +#: src/Admin/ConnectionTester.php:212 +msgid "No default company is set. Choose one below, or pass company_id in every shortcode." +msgstr "" + +#: src/Frontend/Shortcodes/ImageShortcode.php:104 +msgid "No project image is available." +msgstr "" + +#: src/Frontend/Shortcodes/MapShortcode.php:105 +msgid "No project locations are available." +msgstr "" + +#: src/Admin/ConnectionTester.php:298 +msgid "No project was available to test against." +msgstr "" + +#: src/Frontend/Shortcodes/AbstractShortcode.php:392 +msgid "No records were found." +msgstr "" + +#: src/Admin/SettingsPage.php:665 +msgid "No token stored" +msgstr "" + +#: src/Api/Endpoints.php:71 +msgid "None (identity endpoint)" +msgstr "" + +#: src/Frontend/Shortcodes/FieldShortcode.php:181 +msgid "Not available." +msgstr "" + +#: src/Admin/SettingsPage.php:668 +msgid "Not reported yet" +msgstr "" + +#: src/Admin/SettingsPage.php:357 +msgid "Not set" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:373 +msgid "Notes" +msgstr "" + +#: src/Admin/SettingsPage.php:693 +msgid "Nothing recorded. Turn on diagnostics under Tools to start collecting events." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:89 +#: src/Frontend/Shortcodes/Registrar.php:215 +#: src/Frontend/Shortcodes/Registrar.php:249 +#: src/Frontend/Shortcodes/Registrar.php:279 +#: src/Frontend/Shortcodes/Registrar.php:339 +#: src/Frontend/Shortcodes/Registrar.php:393 +msgid "Number" +msgstr "" + +#: src/Admin/SettingsPage.php:677 +msgid "Object cache" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:344 +msgid "Observation" +msgstr "" + +#: src/Api/Endpoints.php:221 +#: src/Frontend/Shortcodes/Registrar.php:333 +msgid "Observations" +msgstr "" + +#: src/Api/Endpoints.php:227 +msgid "Observations: Read Only" +msgstr "" + +#. translators: 1: grant type label, 2: human readable time difference. +#: src/Admin/ConnectionTester.php:126 +msgid "Obtained via %1$s. Valid for another %2$s." +msgstr "" + +#: src/Admin/SettingsPage.php:603 +msgid "Off by default, so deleting the plugin removes its credentials, tokens and cache." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:483 +msgid "Office" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:478 +msgid "Offices" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:479 +msgid "Offices registered against the Procore company." +msgstr "" + +#: src/Admin/SettingsPage.php:328 +msgid "Only used when the environment is set to Custom, for regional and federal zones." +msgstr "" + +#. translators: %s: human readable time difference. +#: src/Admin/SettingsPage.php:672 +msgid "Open — resumes in %s" +msgstr "" + +#: src/Admin/Notices.php:123 +msgid "OpenSSL is not available on this server, so your Procore Client Secret cannot be encrypted in the database. Define PROCOREWP_CLIENT_SECRET in wp-config.php instead." +msgstr "" + +#: src/Admin/ConnectionTester.php:82 +msgid "OpenSSL is unavailable, so credentials cannot be encrypted at rest. Define PROCOREWP_CLIENT_SECRET in wp-config.php instead." +msgstr "" + +#: src/Admin/SettingsPage.php:403 +msgid "Optional. Used whenever a project-scoped shortcode omits project_id." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:304 +msgid "Outstanding punch list items for a project." +msgstr "" + +#: src/Admin/SettingsPage.php:654 +msgid "PHP" +msgstr "" + +#: src/Admin/SettingsPage.php:677 +msgid "Persistent" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:464 +#: src/Frontend/Shortcodes/Registrar.php:498 +msgid "Phone" +msgstr "" + +#: src/Admin/SettingsPage.php:652 +msgid "Plugin version" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:122 +msgid "Postcode" +msgstr "" + +#: src/Admin/SettingsPage.php:436 +msgid "Prefixed to monetary values such as change order totals." +msgstr "" + +#: src/Admin/SettingsPage.php:417 +msgid "Privacy" +msgstr "" + +#: src/Admin/SettingsPage.php:60 +#: src/Frontend/Shortcodes/ImageShortcode.php:77 +msgid "Procore" +msgstr "" + +#: src/Api/Auth/AbstractAuth.php:101 +msgid "Procore did not return an access token." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:158 +msgid "Procore did not return an authorization code." +msgstr "" + +#: src/Admin/ConnectionTester.php:259 +msgid "Procore has not reported a rate limit yet." +msgstr "" + +#: src/Api/Client.php:415 +msgid "Procore is rate limiting this site. Cached data will be shown until the limit resets." +msgstr "" + +#: src/Admin/SettingsPage.php:618 +msgid "Procore permission needed" +msgstr "" + +#: src/Admin/SettingsPage.php:506 +msgid "Procore recommends keeping this at or below 2000." +msgstr "" + +#: src/Api/Client.php:394 +msgid "Procore rejected the access token." +msgstr "" + +#: src/Api/Auth/AbstractAuth.php:84 +msgid "Procore rejected the authentication request (HTTP %d)." +msgstr "" + +#. translators: %s: human readable time difference. +#: src/Admin/Notices.php:164 +msgid "Procore requests are paused after repeated failures and will resume in %s. Cached data is being served in the meantime." +msgstr "" + +#: src/Api/Client.php:225 +msgid "Procore requests are paused after repeated failures. They will resume automatically." +msgstr "" + +#: src/Api/Client.php:467 +msgid "Procore returned HTTP %d." +msgstr "" + +#: src/Api/Client.php:482 +msgid "Procore returned a response that could not be decoded." +msgstr "" + +#: src/Api/Auth/AbstractAuth.php:75 +msgid "Procore returned an unreadable authentication response." +msgstr "" + +#: src/Admin/ConnectionTester.php:178 +msgid "Procore returned no companies. Ask a company administrator to install the app and grant it access." +msgstr "" + +#. translators: %s: shortcode display name, e.g. "RFIs". +#: src/Blocks/Registrar.php:149 +#: src/Blocks/Registrar.php:156 +msgid "Procore: %s" +msgstr "" + +#: src/Admin/SettingsPage.php:59 +#: src/Admin/SettingsPage.php:156 +msgid "ProcoreWP" +msgstr "" + +#. translators: %s: error message from the Procore API. +#: src/Frontend/Shortcodes/AbstractShortcode.php:359 +msgid "ProcoreWP (visible to administrators only): %s" +msgstr "" + +#. translators: %s: settings screen URL. +#: src/Admin/Notices.php:102 +msgid "ProcoreWP imported your settings from version 1.x. Version 1.x authenticated against the wrong Procore host, so you need to re-run the connection test before shortcodes will return data." +msgstr "" + +#. translators: 1: environment label, 2: API host. +#: src/Admin/Notices.php:141 +msgid "ProcoreWP is pointed at %1$s (%2$s). Front-end shortcodes are showing sandbox data." +msgstr "" + +#: src/Api/Environment.php:53 +msgid "Production" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:84 +msgid "Project" +msgstr "" + +#: src/Api/Endpoints.php:126 +msgid "Project Admin: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:136 +#: src/Api/Endpoints.php:146 +msgid "Project Directory: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:121 +msgid "Project detail" +msgstr "" + +#: src/Frontend/Shortcodes/AbstractShortcode.php:366 +#: src/Rest/Controller.php:209 +msgid "Project information is temporarily unavailable." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:507 +msgid "Project locations" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:127 +msgid "Project number" +msgstr "" + +#: src/Api/Endpoints.php:131 +#: src/Frontend/Shortcodes/Registrar.php:156 +msgid "Project team" +msgstr "" + +#: src/Api/Endpoints.php:141 +msgid "Project vendors" +msgstr "" + +#: src/Api/Endpoints.php:110 +#: src/Frontend/Shortcodes/Registrar.php:66 +msgid "Projects" +msgstr "" + +#. translators: %d: company ID. +#: src/Admin/ConnectionTester.php:238 +msgid "Projects are readable for company %d." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:508 +msgid "Projects that have coordinates, as an accessible location list with geo microdata." +msgstr "" + +#: src/Api/Endpoints.php:216 +msgid "Punch List: Read Only" +msgstr "" + +#: src/Api/Endpoints.php:210 +#: src/Frontend/Shortcodes/Registrar.php:303 +msgid "Punch list" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:334 +msgid "Quality and safety observations recorded on a project." +msgstr "" + +#: src/Admin/SettingsPage.php:559 +msgid "REST access" +msgstr "" + +#: src/Admin/SettingsPage.php:551 +msgid "REST proxy" +msgstr "" + +#: src/Api/Endpoints.php:190 +#: src/Frontend/Shortcodes/Registrar.php:243 +msgid "RFIs" +msgstr "" + +#: src/Api/Endpoints.php:195 +msgid "RFIs: Read Only" +msgstr "" + +#: src/Admin/ConnectionTester.php:259 +#: src/Admin/ConnectionTester.php:265 +#: src/Admin/SettingsPage.php:666 +msgid "Rate limit" +msgstr "" + +#: src/Admin/ConnectionTester.php:330 +msgid "Readable." +msgstr "" + +#. translators: 1: environment label, 2: API host. +#: src/Admin/ConnectionTester.php:91 +msgid "Ready. Environment: %1$s (%2$s)." +msgstr "" + +#: src/Admin/SettingsPage.php:690 +msgid "Recent diagnostics" +msgstr "" + +#: src/Admin/SettingsPage.php:421 +msgid "Recommended. Procore project directories contain personal data; publishing them exposes staff and subcontractors to harvesting. When this is off, addresses are still obfuscated and each shortcode must additionally opt in with show_email=\"true\"." +msgstr "" + +#: src/Admin/SettingsPage.php:594 +msgid "Record API diagnostics" +msgstr "" + +#: src/Admin/SettingsPage.php:501 +msgid "Records per request" +msgstr "" + +#: src/Admin/SettingsPage.php:658 +msgid "Redirect URI" +msgstr "" + +#: src/Admin/SettingsPage.php:586 +msgid "Register ProcoreWP blocks" +msgstr "" + +#: src/Admin/SettingsPage.php:229 +msgid "Register this exact redirect URI in the Procore Developer Portal before connecting:" +msgstr "" + +#. translators: %d: number of cache entries removed. +#: src/Admin/Ajax.php:77 +#: src/Cli/Commands.php:101 +msgid "Removed %d cached response." +msgid_plural "Removed %d cached responses." +msgstr[0] "" +msgstr[1] "" + +#: src/Frontend/Shortcodes/Registrar.php:517 +msgid "Render any endpoint published in the Procore endpoint registry." +msgstr "" + +#: src/Admin/SettingsPage.php:510 +msgid "Request timeout" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:244 +msgid "Requests for information raised on a project." +msgstr "" + +#: src/Admin/SettingsPage.php:484 +msgid "Response cache" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:225 +msgid "Revision" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:166 +msgid "Role" +msgstr "" + +#: src/Admin/SettingsPage.php:113 +msgid "Running checks…" +msgstr "" + +#: src/Admin/SettingsPage.php:257 +msgid "Runs through authentication, company access and every endpoint the shortcodes use, so you can see exactly which Procore tool permissions your credentials have." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:417 +msgid "Schedule" +msgstr "" + +#: src/Api/Endpoints.php:254 +msgid "Schedule tasks" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:418 +msgid "Schedule tasks and milestones for a project." +msgstr "" + +#: src/Api/Endpoints.php:259 +msgid "Schedule: Read Only" +msgstr "" + +#: src/Admin/SettingsPage.php:515 +msgid "Seconds to wait for Procore before giving up." +msgstr "" + +#: src/Admin/SettingsPage.php:497 +msgid "Seconds. Shortcodes may request a longer lifetime but never a shorter one, so a single page cannot be configured into a rate-limit breach." +msgstr "" + +#: src/Api/Auth/ClientCredentials.php:94 +msgid "Service Account (Client Credentials)" +msgstr "" + +#: src/Admin/SettingsPage.php:279 +msgid "Service Account (Client Credentials) — recommended" +msgstr "" + +#: src/Plugin.php:162 +msgid "Settings" +msgstr "" + +#: src/Admin/SettingsPage.php:616 +msgid "Shortcode" +msgstr "" + +#: src/Admin/SettingsPage.php:146 +msgid "Shortcodes" +msgstr "" + +#: src/Admin/SettingsPage.php:119 +msgid "Skipped" +msgstr "" + +#: src/Api/Endpoints.php:176 +msgid "Specification sections" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:211 +msgid "Specification sections published for a project." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:210 +msgid "Specifications" +msgstr "" + +#: src/Api/Endpoints.php:182 +msgid "Specifications: Read Only" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:132 +msgid "Stage" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:427 +msgid "Start" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:137 +msgid "Start date" +msgstr "" + +#: src/Admin/SettingsPage.php:147 +#: src/Frontend/Shortcodes/Registrar.php:99 +#: src/Frontend/Shortcodes/Registrar.php:147 +#: src/Frontend/Shortcodes/Registrar.php:259 +#: src/Frontend/Shortcodes/Registrar.php:289 +#: src/Frontend/Shortcodes/Registrar.php:319 +#: src/Frontend/Shortcodes/Registrar.php:349 +#: src/Frontend/Shortcodes/Registrar.php:378 +#: src/Frontend/Shortcodes/Registrar.php:403 +msgid "Status" +msgstr "" + +#: src/Admin/SettingsPage.php:488 +msgid "Strongly recommended. Procore enforces both an hourly and a ten-second rate limit; an uncached page will exhaust the quota quickly. When caching is on, a Procore outage serves the last good response instead of an error." +msgstr "" + +#: src/Admin/SettingsPage.php:441 +msgid "Stylesheet" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:254 +msgid "Subject" +msgstr "" + +#: src/Api/Endpoints.php:200 +#: src/Frontend/Shortcodes/Registrar.php:273 +msgid "Submittals" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:274 +msgid "Submittals tracked on a project." +msgstr "" + +#: src/Api/Endpoints.php:205 +msgid "Submittals: Read Only" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:422 +msgid "Task" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:157 +msgid "Team members assigned to a project. Email addresses are hidden unless explicitly enabled." +msgstr "" + +#: src/Admin/SettingsPage.php:462 +msgid "Template overrides" +msgstr "" + +#: src/Admin/SettingsPage.php:260 +msgid "Test connection" +msgstr "" + +#: src/Admin/Notices.php:76 +msgid "The Procore authorization request was declined." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:149 +msgid "The Procore authorization response could not be verified. Please start the connection again." +msgstr "" + +#: src/Admin/Notices.php:78 +msgid "The Procore connection could not be completed." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:101 +msgid "The Procore connection has expired. Open Procore → Connection and reconnect." +msgstr "" + +#: src/Api/Client.php:425 +msgid "The Procore request could not be completed." +msgstr "" + +#: src/Rest/Controller.php:177 +msgid "The ProcoreWP REST proxy is disabled." +msgstr "" + +#. translators: 1: requested endpoint slug, 2: comma-separated list of endpoint slugs. +#: src/Frontend/Shortcodes/DataShortcode.php:66 +msgid "The endpoint \"%1$s\" is not available to shortcodes. Available endpoints: %2$s" +msgstr "" + +#. translators: %s: comma-separated list of endpoint slugs. +#: src/Frontend/Shortcodes/DataShortcode.php:55 +msgid "The endpoint attribute is required. Available endpoints: %s" +msgstr "" + +#. translators: %s: requested field name. +#: src/Frontend/Shortcodes/FieldShortcode.php:112 +msgid "The field \"%s\" is not available for display. Add it with the procorewp_allowed_project_fields filter." +msgstr "" + +#: src/Frontend/Shortcodes/FieldShortcode.php:103 +msgid "The field attribute is required." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:185 +msgid "The logo or featured image for a project." +msgstr "" + +#: src/Admin/SettingsPage.php:116 +msgid "The request failed. Check your connection and try again." +msgstr "" + +#: src/Admin/SettingsPage.php:360 +msgid "The stored secret is never shown. Leave this blank to keep the current value." +msgstr "" + +#: src/Admin/SettingsPage.php:445 +msgid "The stylesheet loads only on pages that actually contain ProcoreWP output. Turn it off entirely if your theme styles the markup itself." +msgstr "" + +#: src/Frontend/Shortcodes/AbstractShortcode.php:216 +msgid "This shortcode is not bound to a known Procore endpoint." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:81 +msgid "This site is not connected to Procore." +msgstr "" + +#: src/Api/Auth/AuthorizationCode.php:56 +msgid "This site is not connected to Procore. Open Procore → Connection and choose \"Connect to Procore\"." +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:284 +#: src/Frontend/Shortcodes/Registrar.php:398 +msgid "Title" +msgstr "" + +#: src/Admin/SettingsPage.php:464 +msgid "To change the markup, copy a file from the plugin's templates directory into your theme:" +msgstr "" + +#: src/Admin/SettingsPage.php:663 +msgid "Token expires" +msgstr "" + +#: src/Admin/SettingsPage.php:148 +msgid "Tools" +msgstr "" + +#: src/Admin/SettingsPage.php:662 +msgid "Unencrypted — OpenSSL unavailable" +msgstr "" + +#: src/Admin/SettingsPage.php:599 +msgid "Uninstall" +msgstr "" + +#: src/Api/Client.php:172 +#: src/Api/Endpoints.php:333 +msgid "Unknown Procore endpoint: %s" +msgstr "" + +#: src/Admin/SettingsPage.php:390 +msgid "Used whenever a shortcode omits company_id. Also visible in your Procore URL: app.procore.com/companies/XXXX/." +msgstr "" + +#: src/Admin/SettingsPage.php:280 +#: src/Api/Auth/AuthorizationCode.php:213 +msgid "User Account (Authorization Code)" +msgstr "" + +#: src/Admin/SettingsPage.php:564 +msgid "Users who can edit posts" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:408 +msgid "Value" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:449 +msgid "Vendors" +msgstr "" + +#: src/Admin/SettingsPage.php:256 +msgid "Verify the connection" +msgstr "" + +#. translators: %s: project name. +#: templates/map.php:56 +msgid "View %s on a map" +msgstr "" + +#: src/Frontend/Shortcodes/Registrar.php:469 +msgid "Website" +msgstr "" + +#: src/Admin/SettingsPage.php:617 +msgid "What it shows" +msgstr "" + +#: src/Admin/SettingsPage.php:699 +msgid "When" +msgstr "" + +#: src/Admin/SettingsPage.php:653 +msgid "WordPress" +msgstr "" + +#: src/Support/Arr.php:82 +msgid "Yes" +msgstr "" + +#: src/Admin/OAuthController.php:77 +msgid "You do not have permission to complete this connection." +msgstr "" + +#: src/Admin/OAuthController.php:48 +msgid "You do not have permission to connect this site to Procore." +msgstr "" + +#: src/Admin/Ajax.php:170 +msgid "You do not have permission to do that." +msgstr "" + +#: src/Admin/SettingsPage.php:139 +msgid "You do not have permission to view this page." +msgstr "" + +#: src/Admin/ConnectionTester.php:156 +msgid "no login" +msgstr "" + +#: src/Admin/ConnectionTester.php:155 +msgid "service account" +msgstr "" + +#: src/Admin/SettingsPage.php:661 +msgid "wp-config.php constants" +msgstr "" diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..88fd269 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,45 @@ +client_returning( array( $this->response( array( array( 'id' => 1 ) ) ) ), $calls ); + + $client->fetch( 'projects', array(), array( 'company_id' => 4242 ) ); + + $this->assertSame( '4242', $calls[0]['args']['headers']['Procore-Company-Id'] ); + $this->assertSame( 'Bearer test-token', $calls[0]['args']['headers']['Authorization'] ); + } + + /** + * Requests must go to the API host, never the login host. + * + * @return void + */ + public function test_requests_target_the_api_host(): void { + $calls = array(); + $client = $this->client_returning( array( $this->response( array() ) ), $calls ); + + $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertStringStartsWith( 'https://api.procore.com/rest/', $calls[0]['url'] ); + } + + /** + * A timeout and an identifying user agent must always be set. + * + * @return void + */ + public function test_sets_timeout_and_user_agent(): void { + $calls = array(); + $client = $this->client_returning( array( $this->response( array() ) ), $calls ); + + $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertSame( 15, $calls[0]['args']['timeout'] ); + $this->assertTrue( $calls[0]['args']['sslverify'] ); + $this->assertStringContainsString( 'ProcoreWP/2.0.0', $calls[0]['args']['user-agent'] ); + } + + /** + * `all` must follow the Link header rather than guessing page numbers. + * + * @return void + */ + public function test_follows_link_header_for_pagination(): void { + $calls = array(); + $client = $this->client_returning( + array( + $this->response( + array( array( 'id' => 1 ), array( 'id' => 2 ) ), + 200, + array( 'Link' => '; rel="next"' ) + ), + $this->response( array( array( 'id' => 3 ) ) ), + ), + $calls + ); + + $rows = $client->fetch( + 'projects', + array(), + array( + 'company_id' => 1, + 'all' => true, + ) + ); + + $this->assertCount( 3, $rows ); + $this->assertCount( 2, $calls ); + $this->assertSame( array( 1, 2, 3 ), array_column( $rows, 'id' ) ); + } + + /** + * A Link header pointing off-host must never be followed. + * + * @return void + */ + public function test_ignores_link_header_on_a_foreign_host(): void { + $calls = array(); + $client = $this->client_returning( + array( + $this->response( + array( array( 'id' => 1 ) ), + 200, + array( 'Link' => '; rel="next"' ) + ), + ), + $calls + ); + + $rows = $client->fetch( + 'projects', + array(), + array( + 'company_id' => 1, + 'all' => true, + ) + ); + + $this->assertCount( 1, $rows ); + $this->assertCount( 1, $calls ); + } + + /** + * A 429 must be retried after backoff rather than surfaced immediately. + * + * @return void + */ + public function test_retries_after_a_rate_limit_response(): void { + $calls = array(); + $client = $this->client_returning( + array( + $this->response( array( 'message' => 'slow down' ), 429, array( 'Retry-After' => '1' ) ), + $this->response( array( array( 'id' => 7 ) ) ), + ), + $calls + ); + + $rows = $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertCount( 2, $calls ); + $this->assertSame( 7, $rows[0]['id'] ); + } + + /** + * Rate-limit headers must be recorded for the Status screen. + * + * @return void + */ + public function test_records_rate_limit_headers(): void { + $client = $this->client_returning( + array( + $this->response( + array(), + 200, + array( + 'X-Rate-Limit-Limit' => '3600', + 'X-Rate-Limit-Remaining' => '3211', + 'X-Rate-Limit-Reset' => '1893456000', + ) + ), + ) + ); + + $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $limit = Client::rate_limit(); + + $this->assertSame( 3600, $limit['limit'] ); + $this->assertSame( 3211, $limit['remaining'] ); + } + + /** + * A cached response must be served without a second HTTP call. + * + * @return void + */ + public function test_serves_from_cache_on_the_second_call(): void { + $calls = array(); + $client = $this->client_returning( array( $this->response( array( array( 'id' => 5 ) ) ) ), $calls ); + + $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + $second = $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertCount( 1, $calls ); + $this->assertSame( 5, $second[0]['id'] ); + $this->assertTrue( $client->meta()['cached'] ); + } + + /** + * A Procore outage must serve the last good payload, not an error. + * + * @return void + */ + public function test_falls_back_to_stale_cache_on_failure(): void { + $client = $this->client_returning( array( $this->response( array( array( 'id' => 9 ) ) ) ) ); + $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + // Expire the fresh entry but leave the stale copy in place. + foreach ( array_keys( $GLOBALS['procorewp_test_transients'] ) as $key ) { + if ( 0 === strpos( (string) $key, 'procorewp_c_' ) ) { + delete_transient( (string) $key ); + } + } + + $client->set_transport( + static function () { + return new \WP_Error( 'down', 'Procore is unreachable.' ); + } + ); + + $result = $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertFalse( is_wp_error( $result ) ); + $this->assertSame( 9, $result[0]['id'] ); + $this->assertTrue( $client->meta()['stale'] ); + } + + /** + * Without a stale copy, a hard failure must surface as an error. + * + * @return void + */ + public function test_returns_an_error_when_no_fallback_exists(): void { + $client = $this->client_returning( array( $this->response( array( 'message' => 'Forbidden' ), 403 ) ) ); + + $result = $client->fetch( 'projects', array(), array( 'company_id' => 1 ) ); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'Forbidden', $result->get_error_message() ); + } + + /** + * A company-scoped endpoint must refuse to run without a company. + * + * @return void + */ + public function test_requires_a_company_for_scoped_endpoints(): void { + $client = $this->client_returning( array() ); + + $result = $client->fetch( 'projects' ); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'procorewp_missing_company', $result->get_error_code() ); + } + + /** + * A project-scoped endpoint must refuse to run without a project. + * + * @return void + */ + public function test_requires_a_project_for_project_endpoints(): void { + $client = $this->client_returning( array() ); + + $result = $client->fetch( 'rfis', array(), array( 'company_id' => 1 ) ); + + $this->assertTrue( is_wp_error( $result ) ); + $this->assertSame( 'procorewp_missing_project', $result->get_error_code() ); + } + + /** + * Repeated failures must open the circuit and stop further requests. + * + * @return void + */ + public function test_circuit_breaker_opens_after_repeated_failures(): void { + $client = new Client(); + $client->set_auth( new FakeAuth() ); + $client->set_transport( + static function () { + return new \WP_Error( 'down', 'Procore is unreachable.' ); + } + ); + Client::set_instance( $client ); + + for ( $i = 0; $i < 5; $i++ ) { + $client->fetch( 'projects', array( 'nonce' => $i ), array( 'company_id' => 1 ) ); + } + + $this->assertTrue( $client->circuit_is_open() ); + } + + /** + * A shortcode must not be able to cache below the configured floor. + * + * @return void + */ + public function test_cache_floor_is_enforced(): void { + Settings::set( 'cache_floor', 600 ); + + $this->assertSame( 600, Cache::clamp_ttl( 5 ) ); + $this->assertSame( 900, Cache::clamp_ttl( 900 ) ); + } +} diff --git a/tests/unit/EndpointsTest.php b/tests/unit/EndpointsTest.php new file mode 100644 index 0000000..d7a4688 --- /dev/null +++ b/tests/unit/EndpointsTest.php @@ -0,0 +1,92 @@ +assertFalse( Endpoints::exists( 'company_admin_users' ) ); + + $path = Endpoints::path( 'company_admin_users' ); + + $this->assertTrue( is_wp_error( $path ) ); + $this->assertSame( 'procorewp_unknown_endpoint', $path->get_error_code() ); + } + + /** + * Identity endpoints must stay out of reach of shortcodes and the proxy. + * + * @return void + */ + public function test_identity_endpoints_are_not_public(): void { + $public = Endpoints::public_endpoints(); + + $this->assertArrayNotHasKey( 'me', $public ); + $this->assertArrayNotHasKey( 'companies', $public ); + $this->assertArrayHasKey( 'projects', $public ); + } + + /** + * Context identifiers must be interpolated into the path. + * + * @return void + */ + public function test_path_interpolates_identifiers(): void { + $this->assertSame( '/rest/v1.0/projects/88/rfis', Endpoints::path( 'rfis', 1, 88 ) ); + $this->assertSame( '/rest/v1.0/companies/4/offices', Endpoints::path( 'offices', 4 ) ); + } + + /** + * A path placeholder must never be left unfilled. + * + * @return void + */ + public function test_path_refuses_missing_context(): void { + $this->assertTrue( is_wp_error( Endpoints::path( 'rfis', 1, 0 ) ) ); + $this->assertTrue( is_wp_error( Endpoints::path( 'offices', 0 ) ) ); + } + + /** + * Endpoints that scope by query string must receive those parameters. + * + * @return void + */ + public function test_context_query_is_added_where_required(): void { + $this->assertSame( array( 'company_id' => 7 ), Endpoints::context_query( 'projects', 7 ) ); + $this->assertSame( array( 'project_id' => 12 ), Endpoints::context_query( 'punch_items', 7, 12 ) ); + $this->assertSame( array(), Endpoints::context_query( 'rfis', 7, 12 ) ); + } + + /** + * Every registered endpoint must declare the fields it can render. + * + * @return void + */ + public function test_registry_entries_are_complete(): void { + foreach ( Endpoints::all() as $slug => $definition ) { + $this->assertNotSame( '', $definition['path'], $slug . ' has no path' ); + $this->assertNotSame( '', $definition['label'], $slug . ' has no label' ); + $this->assertGreaterThan( 0, $definition['ttl'], $slug . ' has no cache lifetime' ); + $this->assertIsArray( $definition['fields'], $slug . ' has no fields' ); + $this->assertStringStartsWith( '/rest/', $definition['path'], $slug . ' is not a REST path' ); + } + } +} diff --git a/tests/unit/FakeAuth.php b/tests/unit/FakeAuth.php new file mode 100644 index 0000000..58a8909 --- /dev/null +++ b/tests/unit/FakeAuth.php @@ -0,0 +1,71 @@ +token = $token; + } + + /** + * Return the canned token. + * + * @return string|\WP_Error Token or error. + */ + public function access_token() { + return $this->token; + } + + /** + * Always configured. + * + * @return bool True. + */ + public function is_configured(): bool { + return true; + } + + /** + * Grant type identifier. + * + * @return string Grant type. + */ + public function grant_type(): string { + return 'client_credentials'; + } + + /** + * Human readable name. + * + * @return string Label. + */ + public function label(): string { + return 'Fake'; + } +} diff --git a/tests/unit/SettingsTest.php b/tests/unit/SettingsTest.php new file mode 100644 index 0000000..62fd7ef --- /dev/null +++ b/tests/unit/SettingsTest.php @@ -0,0 +1,163 @@ + 'implicit', + 'environment' => 'staging', + 'rest_access' => 'everyone', + ) + ); + + $this->assertSame( 'client_credentials', $clean['auth_mode'] ); + $this->assertSame( 'production', $clean['environment'] ); + $this->assertSame( 'logged_in', $clean['rest_access'] ); + } + + /** + * Numeric settings must be clamped to their documented ranges. + * + * @return void + */ + public function test_clamps_numeric_settings(): void { + $clean = Settings::sanitize( + array( + 'per_page' => 99999, + 'cache_floor' => 1, + 'request_timeout' => 600, + ) + ); + + $this->assertSame( 2000, $clean['per_page'] ); + $this->assertSame( 30, $clean['cache_floor'] ); + $this->assertSame( 60, $clean['request_timeout'] ); + } + + /** + * The secret must be encrypted before it reaches storage. + * + * @return void + */ + public function test_encrypts_the_client_secret(): void { + $clean = Settings::sanitize( array( 'client_secret' => 'super-secret-value' ) ); + + $this->assertNotSame( 'super-secret-value', $clean['client_secret'] ); + $this->assertSame( 'super-secret-value', Encryption::decrypt( $clean['client_secret'] ) ); + } + + /** + * An empty secret field must leave the stored secret untouched, because the + * form renders a mask rather than the real value. + * + * @return void + */ + public function test_blank_secret_preserves_the_stored_value(): void { + update_option( Settings::OPTION, array( 'client_secret' => Encryption::encrypt( 'existing' ) ) ); + Settings::flush(); + + $clean = Settings::sanitize( array( 'client_secret' => '' ) ); + + $this->assertSame( 'existing', Encryption::decrypt( $clean['client_secret'] ) ); + } + + /** + * An explicit clear request must remove the stored secret. + * + * @return void + */ + public function test_secret_can_be_cleared_explicitly(): void { + update_option( Settings::OPTION, array( 'client_secret' => Encryption::encrypt( 'existing' ) ) ); + Settings::flush(); + + $clean = Settings::sanitize( array( 'clear_client_secret' => '1' ) ); + + $this->assertSame( '', $clean['client_secret'] ); + } + + /** + * Custom hosts must be restricted to HTTPS URLs. + * + * @return void + */ + public function test_custom_hosts_must_be_https(): void { + $clean = Settings::sanitize( + array( + 'custom_api_url' => 'http://api.internal.test', + 'custom_login_url' => 'javascript:alert(1)', + ) + ); + + $this->assertSame( '', $clean['custom_api_url'] ); + $this->assertSame( '', $clean['custom_login_url'] ); + } + + /** + * Custom CSS must never be able to inject markup. + * + * @return void + */ + public function test_custom_css_is_stripped_of_tags(): void { + $clean = Settings::sanitize( array( 'custom_css' => '.a{color:red}' ) ); + + $this->assertStringNotContainsString( '' ); + + $rendered = Format::cell( + $record, + array( + 'key' => 'name', + 'format' => 'text', + ) + ); + + $this->assertStringNotContainsString( '