17 Commits
Author SHA1 Message Date
Keith Solomon 0480abe292 fix: clear legacy _projects_portfolio_github_url on provider switch / empty repo URL 2026-08-09 23:49:06 -05:00
Keith Solomon 981c29ab39 Regenerate translation template for Gitea strings 2026-08-09 23:35:45 -05:00
Keith Solomon 5913c8799f Document Gitea support in README 2026-08-09 23:30:53 -05:00
Keith Solomon d250f6b89f Wire provider includes and bump version to 1.1.0 2026-08-09 23:28:47 -05:00
Keith Solomon 62a049b2c2 Route download redirect through provider interface 2026-08-09 23:26:02 -05:00
Keith Solomon e25431a25d Route REST API responses through provider interface 2026-08-09 23:23:57 -05:00
Keith Solomon 9803b497ab Route single-projects template through provider interface 2026-08-09 23:21:06 -05:00
Keith Solomon d8ce719433 Add provider dropdown and per-project Gitea base URL to metabox 2026-08-09 23:17:51 -05:00
Keith Solomon e60a9eafb3 Surface Gitea settings via projects_portfolio_settings() and test 2026-08-09 23:12:37 -05:00
Keith Solomon 925ef184b9 Add Gitea base URL + token fields to settings page 2026-08-09 23:09:50 -05:00
Keith Solomon 40793cd4bb Add provider-aware helpers; mark legacy wrappers deprecated 2026-08-09 23:06:24 -05:00
Keith Solomon e3b27e8974 Add provider factory with meta + option wiring 2026-08-09 23:02:20 -05:00
Keith Solomon 62a20ed514 Add Gitea provider adapter with normalization and tests 2026-08-09 22:56:16 -05:00
keith 28789f6295 Add GitHub provider adapter with full test coverage 2026-08-09 22:48:58 -05:00
Keith Solomon 4a9f43c850 Scaffold provider interface and test bootstrap 2026-08-09 22:35:23 -05:00
Keith Solomon 730893c1f0 Add Gitea support implementation plan
15-task plan with TDD-style steps, WP PHPUnit tests, and a
provider-interface architecture. Tasks cover scaffolding, both
adapters, factory, settings, metabox, template, REST, download
redirect, version bump, README, translations, and final smoke
testing.
2026-08-09 22:29:13 -05:00
Keith Solomon e924b4833a Add design spec: Gitea support for Projects Portfolio
Adds a provider interface with GitHub and Gitea adapters so each
project can link to either provider. Preserves existing GitHub
behavior bit-for-bit, adds lazy migration from legacy meta, and
ships WP PHPUnit tests.
2026-08-09 22:23:27 -05:00
29 changed files with 6161 additions and 122 deletions
+3
View File
@@ -2,3 +2,6 @@
.vscode/ .vscode/
docs/ docs/
notes/ notes/
vendor/
phpunit.xml
.phpunit.result.cache
+22 -3
View File
@@ -45,8 +45,9 @@ Create a beautifully structured, developer-first showcase directory for your Wor
1. In the WordPress® admin, go to **Projects → Add New**. 1. In the WordPress® admin, go to **Projects → Add New**.
2. Add your project title, description, and featured image. 2. Add your project title, description, and featured image.
3. Select a **Project Type**: Plugin, Theme, or Pattern. 3. Select a **Project Type**: Plugin, Theme, or Pattern.
4. In the sidebar, paste the GitHub Repository URL (e.g., `https://github.com/username/repo`). 4. In the sidebar, choose **Provider** (GitHub or Gitea) and paste the **Repository URL** (e.g., `https://github.com/username/repo` or `https://codeberg.org/username/repo`).
5. Publish. 5. For Gitea projects, optionally set a **Gitea Base URL** to override the global default.
6. Publish.
### Enable Download Link ### Enable Download Link
@@ -55,7 +56,7 @@ Each project automatically gets a unique download endpoint:
Example: Example:
`https://example.com/download/123` `https://example.com/download/123`
This URL fetches the latest `.zip` release from GitHub and increments the download count. This URL fetches the latest `.zip` release from the project's host (GitHub or Gitea) and increments the download count.
## Settings Overview ## Settings Overview
@@ -64,6 +65,8 @@ Navigate to **Projects → Settings** to configure:
### General Settings ### General Settings
- **GitHub API Token** Recommended for authenticated requests (avoids GitHub rate limits). - **GitHub API Token** Recommended for authenticated requests (avoids GitHub rate limits).
- **Default Gitea Base URL** Used when a Gitea project doesn't specify one. Defaults to `https://codeberg.org`.
- **Gitea API Token** Recommended for authenticated Gitea requests.
- **Telemetry** Toggle to help improve the plugin. - **Telemetry** Toggle to help improve the plugin.
### Templates Settings ### Templates Settings
@@ -102,6 +105,22 @@ To avoid hitting rate limits or improve reliability:
2. Generate a token (no scopes required). 2. Generate a token (no scopes required).
3. Paste it into the settings screen under **GitHub API Token**. 3. Paste it into the settings screen under **GitHub API Token**.
## Connect Your Gitea Repo
To connect a project to a Gitea instance (self-hosted or public):
1. Set **Provider** to **Gitea** in the project editor.
2. Paste the full repository URL into **Repository URL** (e.g., `https://codeberg.org/username/repo`).
3. Optionally override the Gitea base URL for this project (leave blank to use the global default).
4. Make sure the repo has a release with a `.zip` asset, or any tag — the plugin will fall back to the source archive URL.
### Optional: Add a Gitea API Token
To avoid rate limits or improve reliability:
1. In your Gitea instance, go to **Settings → Applications** and generate a token.
2. Paste it into the plugin settings screen under **Gitea API Token**.
## How to Zip the Plugin for GitHub Releases ## How to Zip the Plugin for GitHub Releases
When attaching the plugin to a GitHub release: When attaching the plugin to a GitHub release:
+27 -2
View File
@@ -41,8 +41,10 @@ function projects_portfolio_render_settings_page() {
} }
// Retrieve saved settings. // Retrieve saved settings.
$api_token = get_option( 'projects_portfolio_github_api_token', '' ); $api_token = get_option( 'projects_portfolio_github_api_token', '' );
$share_telemetry = get_option( 'projects_portfolio_share_telemetry', '0' ); $gitea_api_token = get_option( 'projects_portfolio_gitea_api_token', '' );
$default_gitea_base_url = get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' );
$share_telemetry = get_option( 'projects_portfolio_share_telemetry', '0' );
// Templates Settings. // Templates Settings.
$templates_settings = [ $templates_settings = [
@@ -83,6 +85,27 @@ function projects_portfolio_render_settings_page() {
</tr> </tr>
</table> </table>
<h2><?php esc_html_e( 'Gitea Settings', 'projects-wp' ); ?></h2>
<table class="form-table">
<tr>
<th scope="row">
<label for="projects_portfolio_default_gitea_base_url"><?php esc_html_e( 'Default Gitea Base URL', 'projects-wp' ); ?></label>
</th>
<td>
<input type="url" id="projects_portfolio_default_gitea_base_url" name="projects_portfolio_default_gitea_base_url" value="<?php echo esc_attr( $default_gitea_base_url ); ?>" class="regular-text" placeholder="https://codeberg.org" />
<p class="description"><?php esc_html_e( 'Used when a Gitea project has no per-project base URL override.', 'projects-wp' ); ?></p>
</td>
</tr>
<tr>
<th scope="row">
<label for="projects_portfolio_gitea_api_token"><?php esc_html_e( 'Gitea API Token', 'projects-wp' ); ?></label>
</th>
<td>
<input type="password" id="projects_portfolio_gitea_api_token" name="projects_portfolio_gitea_api_token" value="<?php echo esc_attr( $gitea_api_token ); ?>" class="regular-text" />
</td>
</tr>
</table>
<h2><?php esc_html_e( 'Templates Settings', 'projects-wp' ); ?></h2> <h2><?php esc_html_e( 'Templates Settings', 'projects-wp' ); ?></h2>
<table class="form-table"> <table class="form-table">
<?php foreach ( $templates_settings as $key => $label ) : <?php foreach ( $templates_settings as $key => $label ) :
@@ -134,6 +157,8 @@ function projects_portfolio_save_settings() {
} }
update_option( 'projects_portfolio_github_api_token', sanitize_text_field( $_POST['projects_portfolio_github_api_token'] ?? '' ) ); update_option( 'projects_portfolio_github_api_token', sanitize_text_field( $_POST['projects_portfolio_github_api_token'] ?? '' ) );
update_option( 'projects_portfolio_gitea_api_token', sanitize_text_field( $_POST['projects_portfolio_gitea_api_token'] ?? '' ) );
update_option( 'projects_portfolio_default_gitea_base_url', esc_url_raw( $_POST['projects_portfolio_default_gitea_base_url'] ?? 'https://codeberg.org' ) );
update_option( 'projects_portfolio_share_telemetry', isset( $_POST['projects_portfolio_share_telemetry'] ) ? '1' : '0' ); update_option( 'projects_portfolio_share_telemetry', isset( $_POST['projects_portfolio_share_telemetry'] ) ? '1' : '0' );
// Templates Settings Keys. // Templates Settings Keys.
+87 -15
View File
@@ -6,15 +6,15 @@ if ( ! defined( 'WPINC' ) ) {
} }
/** /**
* Add GitHub Repository URL meta box. * Add Repository meta box (provider-aware).
* *
* @since 1.0.0 * @since 1.0.0
* @return void * @return void
*/ */
function projects_portfolio_add_meta_boxes() { function projects_portfolio_add_meta_boxes() {
add_meta_box( add_meta_box(
'projects_portfolio_github_url', 'projects_portfolio_repo',
esc_html__( 'GitHub URL', 'projects-wp' ), esc_html__( 'Repository', 'projects-wp' ),
'projects_portfolio_render_meta_box', 'projects_portfolio_render_meta_box',
'projects', 'projects',
'side' 'side'
@@ -23,27 +23,64 @@ function projects_portfolio_add_meta_boxes() {
add_action( 'add_meta_boxes', 'projects_portfolio_add_meta_boxes' ); add_action( 'add_meta_boxes', 'projects_portfolio_add_meta_boxes' );
/** /**
* Renders the GitHub Repository URL meta box for the Projects WP plugin. * Render the Repository meta box.
* *
* Outputs a label and input field for setting the GitHub repository URL, * @param WP_Post $post
* as well as a nonce for security.
*
* @param WP_Post $post The post object currently being edited.
* *
* @since 1.0.0 * @since 1.0.0
* @return void * @return void
*/ */
function projects_portfolio_render_meta_box( $post ) { function projects_portfolio_render_meta_box( $post ) {
wp_nonce_field( 'projects_portfolio_save_meta_box', 'projects_portfolio_meta_box_nonce' ); wp_nonce_field( 'projects_portfolio_save_meta_box', 'projects_portfolio_meta_box_nonce' );
$github_url = get_post_meta( $post->ID, '_projects_portfolio_github_url', true );
echo '<label for="projects_portfolio_github_url">' . __( 'GitHub Repository URL:', 'projects-wp' ) . '</label>'; $provider = get_post_meta( $post->ID, '_projects_portfolio_provider', true );
echo '<input type="url" id="projects_portfolio_github_url" name="projects_portfolio_github_url" value="' . esc_attr( $github_url ) . '" style="width: 100%;" />'; if ( '' === $provider ) {
$provider = 'github';
}
$repo_url = get_post_meta( $post->ID, '_projects_portfolio_repo_url', true );
if ( '' === $repo_url ) {
$repo_url = get_post_meta( $post->ID, '_projects_portfolio_github_url', true );
}
$gitea_base_url = get_post_meta( $post->ID, '_projects_portfolio_gitea_base_url', true );
?>
<p>
<label for="projects_portfolio_provider"><?php esc_html_e( 'Provider', 'projects-wp' ); ?></label>
<select id="projects_portfolio_provider" name="projects_portfolio_provider" style="width: 100%;">
<option value="github" <?php selected( $provider, 'github' ); ?>><?php esc_html_e( 'GitHub', 'projects-wp' ); ?></option>
<option value="gitea" <?php selected( $provider, 'gitea' ); ?>><?php esc_html_e( 'Gitea', 'projects-wp' ); ?></option>
</select>
</p>
<p>
<label for="projects_portfolio_repo_url"><?php esc_html_e( 'Repository URL', 'projects-wp' ); ?></label>
<input type="url" id="projects_portfolio_repo_url" name="projects_portfolio_repo_url" value="<?php echo esc_attr( $repo_url ); ?>" style="width: 100%;" placeholder="https://github.com/owner/repo" />
</p>
<p class="projects-portfolio-gitea-only" <?php echo 'gitea' !== $provider ? 'style="display:none;"' : ''; ?>>
<label for="projects_portfolio_gitea_base_url"><?php esc_html_e( 'Gitea Base URL (optional override)', 'projects-wp' ); ?></label>
<input type="url" id="projects_portfolio_gitea_base_url" name="projects_portfolio_gitea_base_url" value="<?php echo esc_attr( $gitea_base_url ); ?>" style="width: 100%;" placeholder="https://codeberg.org" />
<span class="description"><?php esc_html_e( 'Leave blank to use the default from Settings.', 'projects-wp' ); ?></span>
</p>
<script>
(function(){
var sel = document.getElementById('projects_portfolio_provider');
if (!sel) return;
var gitea = document.querySelector('.projects-portfolio-gitea-only');
function toggle(){
if (!gitea) return;
gitea.style.display = sel.value === 'gitea' ? '' : 'none';
}
sel.addEventListener('change', toggle);
toggle();
})();
</script>
<?php
} }
/** /**
* Saves the GitHub repository URL meta box data for the Projects WP plugin. * Save the Repository meta box.
* *
* @param int $post_id The ID of the post currently being saved. * Performs a lazy migration from legacy `_projects_portfolio_github_url`.
*
* @param int $post_id
* *
* @since 1.0.0 * @since 1.0.0
* @return void * @return void
@@ -55,8 +92,43 @@ function projects_portfolio_save_meta_box( $post_id ) {
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
return; return;
} }
if ( isset( $_POST['projects_portfolio_github_url'] ) ) {
update_post_meta( $post_id, '_projects_portfolio_github_url', esc_url_raw( $_POST['projects_portfolio_github_url'] ) ); $existing_provider = get_post_meta( $post_id, '_projects_portfolio_provider', true );
$existing_repo_url = get_post_meta( $post_id, '_projects_portfolio_repo_url', true );
$legacy_github = get_post_meta( $post_id, '_projects_portfolio_github_url', true );
// Lazy migration: legacy URL present, no explicit choice.
if ( '' === $existing_provider && '' === $existing_repo_url && '' !== $legacy_github ) {
update_post_meta( $post_id, '_projects_portfolio_provider', 'github' );
update_post_meta( $post_id, '_projects_portfolio_repo_url', $legacy_github );
}
if ( isset( $_POST['projects_portfolio_provider'] ) ) {
$provider = ( 'gitea' === $_POST['projects_portfolio_provider'] ) ? 'gitea' : 'github';
update_post_meta( $post_id, '_projects_portfolio_provider', $provider );
}
if ( isset( $_POST['projects_portfolio_repo_url'] ) ) {
$repo_url = esc_url_raw( wp_unslash( $_POST['projects_portfolio_repo_url'] ) );
update_post_meta( $post_id, '_projects_portfolio_repo_url', $repo_url );
// Maintain the legacy key for backward-compat reads (only when this is a GitHub project).
$provider_now = get_post_meta( $post_id, '_projects_portfolio_provider', true );
if ( 'github' === $provider_now ) {
if ( '' === $repo_url ) {
// Clearing the field on a GitHub project should clear the legacy key too.
delete_post_meta( $post_id, '_projects_portfolio_github_url' );
} else {
update_post_meta( $post_id, '_projects_portfolio_github_url', $repo_url );
}
} else {
// Provider is gitea (or any non-github): ensure no stale GitHub URL can
// resurrect via the legacy fallback chain.
delete_post_meta( $post_id, '_projects_portfolio_github_url' );
}
}
if ( isset( $_POST['projects_portfolio_gitea_base_url'] ) ) {
update_post_meta( $post_id, '_projects_portfolio_gitea_base_url', esc_url_raw( wp_unslash( $_POST['projects_portfolio_gitea_base_url'] ) ) );
} }
} }
add_action( 'save_post', 'projects_portfolio_save_meta_box' ); add_action( 'save_post', 'projects_portfolio_save_meta_box' );
+20 -16
View File
@@ -27,9 +27,12 @@ function projects_portfolio_get_projects_data( $request ) {
while ( $query->have_posts() ) { while ( $query->have_posts() ) {
$query->the_post(); $query->the_post();
$project_id = get_the_ID(); $project_id = get_the_ID();
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true ); $repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true );
$github_data = projects_portfolio_get_github_data( $github_url ); if ( '' === $repo_url ) {
$repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
}
$repo_data = projects_portfolio_get_repo_data( $project_id );
// Fetch project-type taxonomy terms (names). // Fetch project-type taxonomy terms (names).
$project_types = wp_get_post_terms( $project_id, 'project-type', [ $project_types = wp_get_post_terms( $project_id, 'project-type', [
@@ -44,23 +47,24 @@ function projects_portfolio_get_projects_data( $request ) {
'thumbnail' => get_the_post_thumbnail_url( $project_id, 'large' ), 'thumbnail' => get_the_post_thumbnail_url( $project_id, 'large' ),
'download_count' => (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true ), 'download_count' => (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true ),
'download_url' => esc_url( site_url( '/download/' . $project_id ) ), 'download_url' => esc_url( site_url( '/download/' . $project_id ) ),
'github_url' => $github_url, 'github_url' => $repo_url,
'provider' => get_post_meta( $project_id, '_projects_portfolio_provider', true ) ?: 'github',
'github_data' => [ 'github_data' => [
'owner' => [ 'owner' => [
'avatar_url' => $github_data['owner']['avatar_url'] ?? '', 'avatar_url' => $repo_data['owner']['avatar_url'] ?? '',
'name' => $github_data['owner']['login'] ?? '', 'name' => $repo_data['owner']['login'] ?? '',
'profile' => $github_data['owner']['html_url'] ?? '', 'profile' => $repo_data['owner']['html_url'] ?? '',
], ],
'last_updated' => ! empty( $github_data['updated_at'] ) 'last_updated' => ! empty( $repo_data['updated_at'] )
? date_i18n( get_option( 'date_format' ), strtotime( $github_data['updated_at'] ) ) ? date_i18n( get_option( 'date_format' ), strtotime( $repo_data['updated_at'] ) )
: '', : '',
'language' => $github_data['language'] ?? '', 'language' => $repo_data['language'] ?? '',
'license' => $github_data['license']['name'] ?? 'None', 'license' => $repo_data['license']['name'] ?? 'None',
'stars' => $github_data['stargazers_count'] ?? 0, 'stars' => $repo_data['stargazers_count'] ?? 0,
'forks' => $github_data['forks_count'] ?? 0, 'forks' => $repo_data['forks_count'] ?? 0,
'issues' => $github_data['open_issues_count'] ?? 0, 'issues' => $repo_data['open_issues_count'] ?? 0,
], ],
'version' => projects_portfolio_get_version_from_github( $github_url ), 'version' => projects_portfolio_get_version( $project_id ),
'project_types' => $project_types, 'project_types' => $project_types,
'content' => get_the_content(), 'content' => get_the_content(),
]; ];
@@ -121,7 +125,7 @@ function projects_portfolio_get_popular_projects( $data ) {
'title' => get_the_title(), 'title' => get_the_title(),
'download_count' => (int) get_post_meta( get_the_ID(), '_projects_portfolio_download_count', true ), 'download_count' => (int) get_post_meta( get_the_ID(), '_projects_portfolio_download_count', true ),
'download_url' => esc_url( site_url( '/download/' . get_the_ID() ) ), 'download_url' => esc_url( site_url( '/download/' . get_the_ID() ) ),
'github_url' => get_post_meta( get_the_ID(), '_projects_portfolio_github_url', true ), 'github_url' => ( $url = get_post_meta( get_the_ID(), '_projects_portfolio_repo_url', true ) ?: get_post_meta( get_the_ID(), '_projects_portfolio_github_url', true ) ),
'permalink' => get_permalink(), 'permalink' => get_permalink(),
]; ];
} }
+18
View File
@@ -0,0 +1,18 @@
{
"name": "keithsolomon/projects-portfolio",
"description": "WordPress plugin — dev-only test dependencies.",
"type": "wordpress-plugin",
"require": {
"php": ">=7.4"
},
"require-dev": {
"phpunit/phpunit": "^9.6",
"brain/monkey": "^2.6",
"yoast/phpunit-polyfills": "^2.0"
},
"config": {
"allow-plugins": {
"php-http/discovery": false
}
}
}
Generated
+2116
View File
File diff suppressed because it is too large Load Diff
+73 -2
View File
@@ -16,8 +16,10 @@ function projects_portfolio_settings() {
if ( $settings === null ) { if ( $settings === null ) {
$settings = [ $settings = [
'github_api_token' => get_option( 'projects_portfolio_github_api_token', '' ), 'github_api_token' => get_option( 'projects_portfolio_github_api_token', '' ),
'share_telemetry' => get_option( 'projects_portfolio_share_telemetry', '0' ), 'gitea_api_token' => get_option( 'projects_portfolio_gitea_api_token', '' ),
'default_gitea_base_url' => get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' ),
'share_telemetry' => get_option( 'projects_portfolio_share_telemetry', '0' ),
'templates' => [ 'templates' => [
'version' => get_option( 'projects_portfolio_templates_version', '0' ), 'version' => get_option( 'projects_portfolio_templates_version', '0' ),
'last_updated' => get_option( 'projects_portfolio_templates_last_updated', '0' ), 'last_updated' => get_option( 'projects_portfolio_templates_last_updated', '0' ),
@@ -41,6 +43,67 @@ function projects_portfolio_settings() {
return $settings; return $settings;
} }
/**
* Fetch normalized repository data for a project.
*
* @since 1.1.0
* @param int $post_id
* @return array|null
*/
function projects_portfolio_get_repo_data( int $post_id ): ?array {
return projects_portfolio_get_provider( $post_id )->get_repo_data();
}
/**
* Resolve the latest release ZIP download URL for a project.
*
* @since 1.1.0
* @param int $post_id
* @return string|null
*/
function projects_portfolio_get_release_url( int $post_id ): ?string {
return projects_portfolio_get_provider( $post_id )->get_release_url();
}
/**
* Fetch the latest tag name for a project.
*
* @since 1.1.0
* @param int $post_id
* @return string
*/
function projects_portfolio_get_version( int $post_id ): string {
return projects_portfolio_get_provider( $post_id )->get_latest_version();
}
/**
* Fetch the repo owner profile data for a project.
*
* @since 1.1.0
* @param int $post_id
* @return array|null
*/
function projects_portfolio_get_owner( int $post_id ): ?array {
$provider = projects_portfolio_get_provider( $post_id );
$repo = $provider->get_repo_data();
$login = $repo['owner']['login'] ?? '';
if ( '' === $login ) {
return null;
}
return $provider->get_owner_data( $login );
}
/**
* Public browse URL for the project's repo.
*
* @since 1.1.0
* @param int $post_id
* @return string
*/
function projects_portfolio_get_repo_browse_url( int $post_id ): string {
return projects_portfolio_get_provider( $post_id )->get_repo_browse_url();
}
/** /**
* GitHub API call for owner data * GitHub API call for owner data
* *
@@ -50,6 +113,8 @@ function projects_portfolio_settings() {
* @return mixed * @return mixed
*/ */
function projects_portfolio_github_owner( $owner_name = NULL ) { function projects_portfolio_github_owner( $owner_name = NULL ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_owner( $post_id )' );
if ( ! $owner_name ) { return; } if ( ! $owner_name ) { return; }
$owner = 'https://api.github.com/users/' . $owner_name; $owner = 'https://api.github.com/users/' . $owner_name;
@@ -87,6 +152,8 @@ function projects_portfolio_github_owner( $owner_name = NULL ) {
* @return mixed * @return mixed
*/ */
function projects_portfolio_get_github_release_url( $github_url ) { function projects_portfolio_get_github_release_url( $github_url ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_release_url( $post_id )' );
$api_token = get_option( 'projects_portfolio_github_api_token', '' ); $api_token = get_option( 'projects_portfolio_github_api_token', '' );
$api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', rtrim( $github_url, '/' ) ) . '/releases/latest'; $api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', rtrim( $github_url, '/' ) ) . '/releases/latest';
@@ -126,6 +193,8 @@ function projects_portfolio_get_github_release_url( $github_url ) {
* @return array|null Associative array of GitHub repo data on success, or null on failure. * @return array|null Associative array of GitHub repo data on success, or null on failure.
*/ */
function projects_portfolio_get_github_data( $github_url ) { function projects_portfolio_get_github_data( $github_url ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_repo_data( $post_id )' );
if ( empty( $github_url ) ) { if ( empty( $github_url ) ) {
error_log( 'GitHub URL is empty.' ); error_log( 'GitHub URL is empty.' );
return null; return null;
@@ -186,6 +255,8 @@ function projects_portfolio_get_github_data( $github_url ) {
* @return string The version number or 'Unknown'. * @return string The version number or 'Unknown'.
*/ */
function projects_portfolio_get_version_from_github( $github_url ) { function projects_portfolio_get_version_from_github( $github_url ) {
_deprecated_function( __FUNCTION__, '1.1.0', 'projects_portfolio_get_version( $post_id )' );
if ( empty( $github_url ) ) { if ( empty( $github_url ) ) {
return 'Unknown'; return 'Unknown';
} }
+229
View File
@@ -0,0 +1,229 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* Gitea adapter for the Repository_Provider interface.
*
* Endpoint base: {base_url}/api/v1
* - repo: {base_url}/api/v1/repos/{owner}/{repo}
* - release: {base_url}/api/v1/repos/{owner}/{repo}/releases/latest (returns array)
* - user: {base_url}/api/v1/users/{login}
* - archive: {base_url}/{owner}/{repo}/archive/refs/tags/{tag}.zip
*
* @since 1.1.0
*/
class Gitea_Provider implements Repository_Provider {
private string $repo_url;
private string $base_url;
private string $api_token;
public function __construct( string $repo_url, string $base_url, string $api_token = '' ) {
$this->repo_url = $repo_url;
$this->base_url = rtrim( $base_url, '/' );
$this->api_token = $api_token;
}
public function get_id(): string {
return 'gitea';
}
public function get_label(): string {
return 'Gitea';
}
public function get_repo_data(): ?array {
$api_url = $this->base_url . '/api/v1/repos/' . $this->owner_repo_path();
if ( empty( $api_url ) ) {
error_log( 'Projects Portfolio: Gitea repo URL is empty.' );
return null;
}
$cache_key = 'projects_portfolio_provider_data_' . md5( $api_url );
$cached_data = get_transient( $cache_key );
if ( $cached_data ) {
return $cached_data;
}
$headers = [ 'Accept' => 'application/json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
} else {
error_log( 'Projects Portfolio: Gitea API token is missing. Using unauthenticated requests.' );
}
$response = wp_remote_get( $api_url, [ 'headers' => $headers ] );
if ( is_wp_error( $response ) ) {
error_log( 'Projects Portfolio: Gitea API error: ' . $response->get_error_message() );
return null;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
error_log( 'Projects Portfolio: Gitea API error: Received status ' . $code );
return null;
}
$raw = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $raw ) || ! is_array( $raw ) ) {
error_log( 'Projects Portfolio: Gitea API error: Invalid data received.' );
return null;
}
$owner = $raw['owner'] ?? [];
$owner_login = $owner['login'] ?? $this->owner_login_from_path();
$data = [
'owner' => [
'avatar_url' => $owner['avatar_url'] ?? '',
'login' => $owner_login,
'html_url' => $owner['html_url'] ?? ( $this->base_url . '/' . $owner_login ),
],
'updated_at' => $raw['updated_at'] ?? '',
'language' => $raw['language'] ?? '',
'license' => $this->normalize_license( $raw['license'] ?? null ),
'stargazers_count' => (int) ( $raw['stars_count'] ?? 0 ),
'forks_count' => (int) ( $raw['forks_count'] ?? 0 ),
'open_issues_count' => (int) ( $raw['open_issues_count'] ?? 0 ),
];
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
return $data;
}
public function get_release_url(): ?string {
$release = $this->fetch_latest_release();
if ( null === $release ) {
return null;
}
$assets = $release['assets'] ?? [];
if ( is_array( $assets ) ) {
foreach ( $assets as $asset ) {
if ( isset( $asset['name'] ) && 'zip' === pathinfo( $asset['name'], PATHINFO_EXTENSION ) ) {
$url = $asset['browser_download_url'] ?? null;
if ( $url ) {
return $url;
}
}
}
}
$tag = $release['tag_name'] ?? '';
if ( '' === $tag ) {
return null;
}
return $this->base_url . '/' . $this->owner_repo_path() . '/archive/refs/tags/' . $tag . '.zip';
}
public function get_latest_version(): string {
$release = $this->fetch_latest_release();
if ( null === $release ) {
return 'Unknown';
}
return isset( $release['tag_name'] ) ? (string) $release['tag_name'] : 'Unknown';
}
public function get_repo_browse_url(): string {
return $this->base_url . '/' . $this->owner_repo_path();
}
public function get_owner_data( string $owner_login ): ?array {
if ( empty( $owner_login ) ) {
return null;
}
$api_url = $this->base_url . '/api/v1/users/' . rawurlencode( $owner_login );
$response = wp_remote_get( $api_url, [ 'headers' => $this->auth_headers() ] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return null;
}
return [
'avatar_url' => $data['avatar_url'] ?? '',
'login' => $data['login'] ?? $owner_login,
'html_url' => $data['html_url'] ?? ( $this->base_url . '/' . $owner_login ),
];
}
/**
* Returns the first release object or null on failure / empty response.
* Gitea's /releases/latest returns an array.
*/
private function fetch_latest_release(): ?array {
$api_url = $this->base_url . '/api/v1/repos/' . $this->owner_repo_path() . '/releases/latest';
$cache_key = 'projects_portfolio_provider_release_' . md5( $api_url );
$cached = get_transient( $cache_key );
if ( $cached ) {
return $cached;
}
$response = wp_remote_get( $api_url, [ 'headers' => $this->auth_headers() ] );
if ( is_wp_error( $response ) ) {
error_log( 'Projects Portfolio: Gitea API error: ' . $response->get_error_message() );
return null;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 200 !== $code ) {
error_log( 'Projects Portfolio: Gitea releases/latest returned status ' . $code );
return null;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $body ) || empty( $body ) ) {
return null;
}
$release = $body[0];
set_transient( $cache_key, $release, HOUR_IN_SECONDS );
return $release;
}
private function auth_headers(): array {
$headers = [ 'Accept' => 'application/json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
}
return $headers;
}
private function owner_repo_path(): string {
// Strip the base URL prefix if present, return "owner/repo".
$path = $this->repo_url;
if ( '' !== $this->base_url && 0 === strpos( $path, $this->base_url ) ) {
$path = substr( $path, strlen( $this->base_url ) );
}
$path = ltrim( $path, '/' );
// Drop trailing slashes and any "/archive/...", "/releases/...", etc.
$path = preg_replace( '#/(archive|releases|tree|blob|issues|pulls|src|commits|wiki)(/.*)?$#', '', $path );
return trim( $path, '/' );
}
private function owner_login_from_path(): string {
$parts = explode( '/', $this->owner_repo_path() );
return $parts[0] ?? '';
}
private function normalize_license( $license ): array {
if ( is_array( $license ) ) {
$name = $license['name'] ?? '';
return [ 'name' => '' !== $name ? $name : 'None' ];
}
if ( is_string( $license ) && '' !== $license ) {
return [ 'name' => $license ];
}
return [ 'name' => 'None' ];
}
}
@@ -0,0 +1,195 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* GitHub adapter for the Repository_Provider interface.
*
* Preserves the GitHub URL/header/asset behavior from the original
* helper-functions.php verbatim.
*
* @since 1.1.0
*/
class GitHub_Provider implements Repository_Provider {
private string $repo_url;
private string $api_token;
public function __construct( string $repo_url, string $api_token = '' ) {
$this->repo_url = rtrim( $repo_url, '/' );
$this->api_token = $api_token;
}
public function get_id(): string {
return 'github';
}
public function get_label(): string {
return 'GitHub';
}
public function get_repo_data(): ?array {
if ( empty( $this->repo_url ) ) {
error_log( 'Projects Portfolio: GitHub URL is empty.' );
return null;
}
$api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', $this->repo_url );
$cache_key = 'projects_portfolio_provider_data_' . md5( $api_url );
$cached_data = get_transient( $cache_key );
if ( $cached_data ) {
return $cached_data;
}
$headers = [ 'Accept' => 'application/vnd.github.v3+json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
} else {
error_log( 'Projects Portfolio: GitHub API token is missing. Using unauthenticated requests.' );
}
$response = wp_remote_get( $api_url, [ 'headers' => $headers ] );
if ( is_wp_error( $response ) ) {
error_log( 'Projects Portfolio: GitHub API error: ' . $response->get_error_message() );
return null;
}
$code = wp_remote_retrieve_response_code( $response );
if ( 403 === $code ) {
$remaining = wp_remote_retrieve_header( $response, 'x-ratelimit-remaining' );
$reset = wp_remote_retrieve_header( $response, 'x-ratelimit-reset' );
error_log( 'Projects Portfolio: GitHub API 403: Rate limit exceeded. Remaining: ' . $remaining . ' Reset at: ' . date( 'Y-m-d H:i:s', (int) $reset ) );
return null;
}
if ( 200 !== $code ) {
error_log( 'Projects Portfolio: GitHub API error: Received status ' . $code );
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( empty( $data ) || ! is_array( $data ) ) {
error_log( 'Projects Portfolio: GitHub API error: Invalid data received.' );
return null;
}
set_transient( $cache_key, $data, HOUR_IN_SECONDS );
return $data;
}
public function get_release_url(): ?string {
$api_url = $this->release_api_url();
if ( null === $api_url ) {
return null;
}
$cache_key = 'projects_portfolio_provider_release_' . md5( $api_url );
$cached = get_transient( $cache_key );
if ( $cached ) {
return $cached;
}
$headers = [ 'Accept' => 'application/vnd.github.v3+json' ];
if ( ! empty( $this->api_token ) ) {
$headers['Authorization'] = 'token ' . $this->api_token;
}
$response = wp_remote_get( $api_url, [ 'headers' => $headers ] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return null;
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $data ) ) {
return null;
}
if ( ! empty( $data['assets'] ) && is_array( $data['assets'] ) ) {
foreach ( $data['assets'] as $asset ) {
if ( isset( $asset['name'] ) && 'zip' === pathinfo( $asset['name'], PATHINFO_EXTENSION ) ) {
$url = $asset['browser_download_url'] ?? null;
if ( $url ) {
set_transient( $cache_key, $url, HOUR_IN_SECONDS );
return $url;
}
}
}
}
$zipball = $data['zipball_url'] ?? null;
if ( $zipball ) {
set_transient( $cache_key, $zipball, HOUR_IN_SECONDS );
}
return $zipball;
}
public function get_latest_version(): string {
$api_url = $this->release_api_url();
if ( null === $api_url ) {
return 'Unknown';
}
$cache_key = 'projects_portfolio_provider_release_version_' . md5( $api_url );
$cached = get_transient( $cache_key );
if ( $cached ) {
return $cached;
}
$response = wp_remote_get( $api_url, [] );
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return 'Unknown';
}
$data = json_decode( wp_remote_retrieve_body( $response ), true );
$tag = is_array( $data ) && isset( $data['tag_name'] ) ? (string) $data['tag_name'] : 'Unknown';
set_transient( $cache_key, $tag, HOUR_IN_SECONDS );
return $tag;
}
public function get_repo_browse_url(): string {
return $this->repo_url;
}
public function get_owner_data( string $owner_login ): ?array {
if ( empty( $owner_login ) ) {
return null;
}
$api_url = 'https://api.github.com/users/' . $owner_login;
$ctx = stream_context_create( [
'http' => [
'method' => 'GET',
'header' => "User-Agent: WORDPRESS\r\n",
],
] );
$body = @file_get_contents( $api_url, false, $ctx );
if ( false === $body ) {
error_log( 'Projects Portfolio: Error fetching data from GitHub API.' );
return null;
}
$data = json_decode( $body, true );
if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) {
error_log( 'Projects Portfolio: Error decoding JSON data: ' . json_last_error_msg() );
return null;
}
return [
'avatar_url' => $data['avatar_url'] ?? '',
'login' => $data['login'] ?? $owner_login,
'html_url' => $data['html_url'] ?? ( 'https://github.com/' . $owner_login ),
];
}
private function release_api_url(): ?string {
if ( empty( $this->repo_url ) ) {
return null;
}
return str_replace( 'https://github.com/', 'https://api.github.com/repos/', $this->repo_url ) . '/releases/latest';
}
}
@@ -0,0 +1,36 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* Provider factory: selects the right adapter for a project post.
*
* @since 1.1.0
*
* @param int $post_id
* @return Repository_Provider
*/
function projects_portfolio_get_provider( int $post_id ): Repository_Provider {
$provider_meta = (string) get_post_meta( $post_id, '_projects_portfolio_provider', true );
$provider_id = '' !== $provider_meta ? $provider_meta : 'github';
$repo_url = (string) get_post_meta( $post_id, '_projects_portfolio_repo_url', true );
if ( '' === $repo_url ) {
// Lazy fallback to legacy key.
$repo_url = (string) get_post_meta( $post_id, '_projects_portfolio_github_url', true );
}
if ( 'gitea' === $provider_id ) {
$base_url = (string) get_post_meta( $post_id, '_projects_portfolio_gitea_base_url', true );
if ( '' === $base_url ) {
$base_url = (string) get_option( 'projects_portfolio_default_gitea_base_url', 'https://codeberg.org' );
}
$token = (string) get_option( 'projects_portfolio_gitea_api_token', '' );
return new Gitea_Provider( $repo_url, $base_url, $token );
}
$token = (string) get_option( 'projects_portfolio_github_api_token', '' );
return new GitHub_Provider( $repo_url, $token );
}
@@ -0,0 +1,47 @@
<?php
// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
die;
}
/**
* Contract for a repository host adapter (GitHub, Gitea, ...).
*
* @since 1.1.0
*/
interface Repository_Provider {
public function get_id(): string;
public function get_label(): string;
/**
* Normalized repo metadata. Returns null on any failure.
*
* Shape:
* [
* 'owner' => [ 'avatar_url' => string, 'login' => string, 'html_url' => string ],
* 'updated_at' => string (ISO 8601),
* 'language' => string,
* 'license' => [ 'name' => string ],
* 'stargazers_count' => int,
* 'forks_count' => int,
* 'open_issues_count' => int,
* ]
*/
public function get_repo_data(): ?array;
/** Direct URL to the latest release zip asset, or null. */
public function get_release_url(): ?string;
/** Tag name of the latest release, or 'Unknown'. */
public function get_latest_version(): string;
/** Public URL of the repo, for the 'View Repo' button. */
public function get_repo_browse_url(): string;
/**
* Owner profile data: [ 'avatar_url', 'login', 'html_url' ].
* Returns null on failure.
*/
public function get_owner_data( string $owner_login ): ?array;
}
@@ -1,3 +1,3 @@
<?php <?php
// generated by Poedit from projects-portfolio-es_MX.po, do not edit directly // generated by Poedit from projects-portfolio-es_MX.po, do not edit directly
return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'es_MX','pot-creation-date'=>'2025-04-04 10:12-0400','po-revision-date'=>'2025-04-04 10:13-0400','translation-revision-date'=>'2025-04-04 10:13-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Ajustes','Version'=>'Versión','Last Updated'=>'Última Actualización','License'=>'Licencia','Language'=>'Idioma','Downloads'=>'Descargas','Forks'=>'Horquillas','Stars'=>'Estrellas','Issues'=>'Problemas','GitHub Owner'=>'Propietario de GitHub','Archive Title'=>'Título del archivo','Project Title'=>'Título del Proyecto','Project Excerpt'=>'Estracto de proyecto','Project Buttons'=>'Botones de proyecto','Projects for WordPress Settings'=>'Proyectos para la configuración de WordPress','General Settings'=>'Ajustes Generales','GitHub API Token'=>'Token de la API de GitHub','Templates Settings'=>'Configuración de plantillas','Archives Settings'=>'Ajustes de los archivos','Save Settings'=>'Guardar Ajustes','Settings saved successfully.'=>'Ajustes guardados exitosamente.','Projects'=>'Proyectos','Project'=>'Proyecto','Add New Project'=>'Agregar Proyecto','Project Types'=>'Proyectos','Project Type'=>'Tipo de Proyecto','Plugin'=>'Plugin','Theme'=>'Tema','Pattern'=>'Patron','GitHub URL'=>'GitHub URL','GitHub Repository URL:'=>'URL del repositorio GitHub:','View details'=>'Ver Detalles','More information about %s'=>'Más información sobre %s','Check for updates'=>'Checar por Actualizaciones','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'El plugin %s está actualizado.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Una nueva versión del plugin %s está disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'No se pudo determinar si las actualizaciones están disponibles para %s.','Unknown update checker status "%s"'=>'Status de actualización desconocido "%s"','There is no changelog available.'=>'No está disponible la lista de cambios.','Invalid download URL. Please check the GitHub repository.'=>'URL de descarga no válida. Compruebe el repositorio de GitHub.','Download Error'=>'Error','Download Count'=>'Descargar el recuento','0'=>'0','Download'=>'Descargar','View Project'=>'Ver Proyecto','&laquo; Previous'=>'&laquo; Anterior','Next &raquo;'=>'Siguiente &raquo;','No projects found.'=>'No se han encontrado proyectos.','Project Banner'=>'Proyecto Banner','Download Now'=>'Descargar Ahora','View on GitHub'=>'Ver en GitHub','GitHub Repository:'=>'Repositorio GitHub:','No repository linked.'=>'Ningún repositorio vinculado.','Version:'=>'Versión:','Updated:'=>'Actualizado:','License:'=>'Licencia:','Language:'=>'Idioma:','Downloads:'=>'Descargas:','Stars:'=>'Estrellas:','Forks:'=>'Horquillas:','Issues:'=>'Problemas:','Follow'=>'Seguir','Projects for WordPress®'=>'Proyectos para WordPress','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Crear un directorio de escaparate para proyectos (plugins, temas, patrones) con tipos de post personalizados, taxonomías y funcionalidad de descarga.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']]; return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'es_MX','pot-creation-date'=>'2025-04-04 10:12-0400','po-revision-date'=>'2025-04-04 10:13-0400','translation-revision-date'=>'2025-04-04 10:13-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Ajustes','Version'=>'Versión','Last Updated'=>'Última Actualización','License'=>'Licencia','Language'=>'Idioma','Downloads'=>'Descargas','Forks'=>'Horquillas','Stars'=>'Estrellas','Issues'=>'Problemas','GitHub Owner'=>'Propietario de GitHub','Archive Title'=>'Título del archivo','Project Title'=>'Título del Proyecto','Project Excerpt'=>'Estracto de proyecto','Project Buttons'=>'Botones de proyecto','Projects for WordPress Settings'=>'Proyectos para la configuración de WordPress','General Settings'=>'Ajustes Generales','GitHub API Token'=>'Token de la API de GitHub','Templates Settings'=>'Configuración de plantillas','Archives Settings'=>'Ajustes de los archivos','Save Settings'=>'Guardar Ajustes','Settings saved successfully.'=>'Ajustes guardados exitosamente.','Projects'=>'Proyectos','Project'=>'Proyecto','Add New Project'=>'Agregar Proyecto','Project Types'=>'Proyectos','Project Type'=>'Tipo de Proyecto','Plugin'=>'Plugin','Theme'=>'Tema','Pattern'=>'Patron','GitHub URL'=>'GitHub URL','GitHub Repository URL:'=>'URL del repositorio GitHub:','View details'=>'Ver Detalles','More information about %s'=>'Más información sobre %s','Check for updates'=>'Checar por Actualizaciones','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'El plugin %s está actualizado.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Una nueva versión del plugin %s está disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'No se pudo determinar si las actualizaciones están disponibles para %s.','Unknown update checker status "%s"'=>'Status de actualización desconocido "%s"','There is no changelog available.'=>'No está disponible la lista de cambios.','Invalid download URL. Please check the GitHub repository.'=>'URL de descarga no válida. Compruebe el repositorio de GitHub.','Invalid download URL. Please check the repository URL.'=>'URL de descarga no válida. Compruebe el repositorio.','Repository'=>'','Provider'=>'','Gitea Base URL (optional override)'=>'','Leave blank to use the default from Settings.'=>'','View Repo'=>'','Gitea Settings'=>'','Default Gitea Base URL'=>'','Used when a Gitea project has no per-project base URL override.'=>'','Gitea API Token'=>'','Download Error'=>'Error','Download Count'=>'Descargar el recuento','0'=>'0','Download'=>'Descargar','View Project'=>'Ver Proyecto','&laquo; Previous'=>'&laquo; Anterior','Next &raquo;'=>'Siguiente &raquo;','No projects found.'=>'No se han encontrado proyectos.','Project Banner'=>'Proyecto Banner','Download Now'=>'Descargar Ahora','View on GitHub'=>'Ver en GitHub','GitHub Repository:'=>'Repositorio GitHub:','No repository linked.'=>'Ningún repositorio vinculado.','Version:'=>'Versión:','Updated:'=>'Actualizado:','License:'=>'Licencia:','Language:'=>'Idioma:','Downloads:'=>'Descargas:','Stars:'=>'Estrellas:','Forks:'=>'Horquillas:','Issues:'=>'Problemas:','Follow'=>'Seguir','Projects for WordPress®'=>'Proyectos para WordPress','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Crear un directorio de escaparate para proyectos (plugins, temas, patrones) con tipos de post personalizados, taxonomías y funcionalidad de descarga.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']];
+40
View File
@@ -88,6 +88,42 @@ msgstr "Ajustes Generales"
msgid "GitHub API Token" msgid "GitHub API Token"
msgstr "Token de la API de GitHub" msgstr "Token de la API de GitHub"
#: admin/admin-settings.php:88
msgid "Gitea Settings"
msgstr ""
#: admin/admin-settings.php:92
msgid "Default Gitea Base URL"
msgstr ""
#: admin/admin-settings.php:96
msgid "Used when a Gitea project has no per-project base URL override."
msgstr ""
#: admin/admin-settings.php:101
msgid "Gitea API Token"
msgstr ""
#: admin/metabox.php:17
msgid "Repository"
msgstr ""
#: admin/metabox.php:47
msgid "Provider"
msgstr ""
#: admin/metabox.php:58
msgid "Gitea Base URL (optional override)"
msgstr ""
#: admin/metabox.php:60
msgid "Leave blank to use the default from Settings."
msgstr ""
#: templates/single-projects.php:73
msgid "View Repo"
msgstr ""
#: admin/admin-settings.php:86 #: admin/admin-settings.php:86
msgid "Templates Settings" msgid "Templates Settings"
msgstr "Configuración de plantillas" msgstr "Configuración de plantillas"
@@ -189,6 +225,10 @@ msgstr "No está disponible la lista de cambios."
msgid "Invalid download URL. Please check the GitHub repository." msgid "Invalid download URL. Please check the GitHub repository."
msgstr "URL de descarga no válida. Compruebe el repositorio de GitHub." msgstr "URL de descarga no válida. Compruebe el repositorio de GitHub."
#: projects-portfolio.php:119
msgid "Invalid download URL. Please check the repository URL."
msgstr "URL de descarga no válida. Compruebe el repositorio."
#: projects-portfolio.php:118 #: projects-portfolio.php:118
msgid "Download Error" msgid "Download Error"
msgstr "Error" msgstr "Error"
+1 -1
View File
@@ -1,3 +1,3 @@
<?php <?php
// generated by Poedit from projects-wp-fr_FR.po, do not edit directly // generated by Poedit from projects-wp-fr_FR.po, do not edit directly
return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'fr_FR','pot-creation-date'=>'2025-04-04 10:13-0400','po-revision-date'=>'2025-04-04 10:14-0400','translation-revision-date'=>'2025-04-04 10:14-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Paramètres','Version'=>'Version','Last Updated'=>'Dernière mise à jour','License'=>'Licence','Language'=>'Langue','Downloads'=>'Téléchargements','Forks'=>'Fourchettes','Stars'=>'Étoiles','Issues'=>'Problèmes','GitHub Owner'=>'Propriétaire de GitHub','Archive Title'=>'Archive Titre','Project Title'=>'Titre du projet','Project Excerpt'=>'Extrait du projet','Project Buttons'=>'Boutons de projet','Projects for WordPress Settings'=>'Projets pour les réglages de WordPress','General Settings'=>'Réglages généraux','GitHub API Token'=>'Token API GitHub','Templates Settings'=>'¨Paramètres des modèles','Archives Settings'=>'Paramètres des archives','Save Settings'=>'Enregistrer les paramètres','Settings saved successfully.'=>'Réglages bien enregistrés.','Projects'=>'Projets','Project'=>'Projet','Add New Project'=>'Ajouter un nouveau projet','Project Types'=>'Types de projets','Project Type'=>'Type de projet','Plugin'=>'Extension','Theme'=>'Thème','Pattern'=>'Motif','GitHub URL'=>'URL GitHub','GitHub Repository URL:'=>'URL du dépôt GitHub :','View details'=>'Voir les détails','More information about %s'=>'Plus dinformations sur %s','Check for updates'=>'Vérifier les mises à jour','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'Le plugin %s est à jour.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Une nouvelle version de l\'extension %s est disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'Impossible de déterminer si des mises à jour sont disponibles pour %s.','Unknown update checker status "%s"'=>'Mise à jour inconnue statut "%s"','There is no changelog available.'=>'Aucun registre de modifications disponible.','Invalid download URL. Please check the GitHub repository.'=>'URL de téléchargement invalide. Veuillez vérifier le dépôt GitHub.','Download Error'=>'Télécharger Erreur','Download Count'=>'Nombre de Téléchargements','0'=>'0','Download'=>'Télécharger','View Project'=>'Voir le projet','&laquo; Previous'=>'&laquo; Précédent','Next &raquo;'=>'Suivant &raquo;','No projects found.'=>'Aucun projet trouvé.','Project Banner'=>'Bannière du projet','Download Now'=>'Télécharger maintenant','View on GitHub'=>'Voir sur GitHub','GitHub Repository:'=>'Dépôt GitHub:','No repository linked.'=>'Aucun dépôt n\'est lié.','Version:'=>'Version:','Updated:'=>'Mis à jour :','License:'=>'Licence:','Language:'=>'Langue:','Downloads:'=>'Téléchargements:','Stars:'=>'Étoiles:','Forks:'=>'Forks:','Issues:'=>'Problèmes:','Follow'=>'Suivre','Projects for WordPress®'=>'Projets pour WordPress®','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Créer un répertoire de présentation pour les projets (plugins, thèmes, modèles) avec des types d\'articles personnalisés, des taxonomies et des fonctionnalités de téléchargement.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']]; return ['domain'=>NULL,'plural-forms'=>'nplurals=2; plural=(n != 1);','language'=>'fr_FR','pot-creation-date'=>'2025-04-04 10:13-0400','po-revision-date'=>'2025-04-04 10:14-0400','translation-revision-date'=>'2025-04-04 10:14-0400','project-id-version'=>'Projects for WordPress®','x-generator'=>'Poedit 3.6','messages'=>['Settings'=>'Paramètres','Version'=>'Version','Last Updated'=>'Dernière mise à jour','License'=>'Licence','Language'=>'Langue','Downloads'=>'Téléchargements','Forks'=>'Fourchettes','Stars'=>'Étoiles','Issues'=>'Problèmes','GitHub Owner'=>'Propriétaire de GitHub','Archive Title'=>'Archive Titre','Project Title'=>'Titre du projet','Project Excerpt'=>'Extrait du projet','Project Buttons'=>'Boutons de projet','Projects for WordPress Settings'=>'Projets pour les réglages de WordPress','General Settings'=>'Réglages généraux','GitHub API Token'=>'Token API GitHub','Templates Settings'=>'¨Paramètres des modèles','Archives Settings'=>'Paramètres des archives','Save Settings'=>'Enregistrer les paramètres','Settings saved successfully.'=>'Réglages bien enregistrés.','Projects'=>'Projets','Project'=>'Projet','Add New Project'=>'Ajouter un nouveau projet','Project Types'=>'Types de projets','Project Type'=>'Type de projet','Plugin'=>'Extension','Theme'=>'Thème','Pattern'=>'Motif','GitHub URL'=>'URL GitHub','GitHub Repository URL:'=>'URL du dépôt GitHub :','View details'=>'Voir les détails','More information about %s'=>'Plus dinformations sur %s','Check for updates'=>'Vérifier les mises à jour','the plugin title' . "\4" . 'The %s plugin is up to date.'=>'Le plugin %s est à jour.','the plugin title' . "\4" . 'A new version of the %s plugin is available.'=>'Une nouvelle version de l\'extension %s est disponible.','the plugin title' . "\4" . 'Could not determine if updates are available for %s.'=>'Impossible de déterminer si des mises à jour sont disponibles pour %s.','Unknown update checker status "%s"'=>'Mise à jour inconnue statut "%s"','There is no changelog available.'=>'Aucun registre de modifications disponible.','Invalid download URL. Please check the GitHub repository.'=>'URL de téléchargement invalide. Veuillez vérifier le dépôt GitHub.','Invalid download URL. Please check the repository URL.'=>'URL de téléchargement invalide. Veuillez vérifier le dépôt.','Repository'=>'','Provider'=>'','Gitea Base URL (optional override)'=>'','Leave blank to use the default from Settings.'=>'','View Repo'=>'','Gitea Settings'=>'','Default Gitea Base URL'=>'','Used when a Gitea project has no per-project base URL override.'=>'','Gitea API Token'=>'','Download Error'=>'Télécharger Erreur','Download Count'=>'Nombre de Téléchargements','0'=>'0','Download'=>'Télécharger','View Project'=>'Voir le projet','&laquo; Previous'=>'&laquo; Précédent','Next &raquo;'=>'Suivant &raquo;','No projects found.'=>'Aucun projet trouvé.','Project Banner'=>'Bannière du projet','Download Now'=>'Télécharger maintenant','View on GitHub'=>'Voir sur GitHub','GitHub Repository:'=>'Dépôt GitHub:','No repository linked.'=>'Aucun dépôt n\'est lié.','Version:'=>'Version:','Updated:'=>'Mis à jour :','License:'=>'Licence:','Language:'=>'Langue:','Downloads:'=>'Téléchargements:','Stars:'=>'Étoiles:','Forks:'=>'Forks:','Issues:'=>'Problèmes:','Follow'=>'Suivre','Projects for WordPress®'=>'Projets pour WordPress®','https://github.com/robertdevore/projects-portfolio/'=>'https://github.com/robertdevore/projects-portfolio/','Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.'=>'Créer un répertoire de présentation pour les projets (plugins, thèmes, modèles) avec des types d\'articles personnalisés, des taxonomies et des fonctionnalités de téléchargement.','Robert DeVore'=>'Robert DeVore','https://www.robertdevore.com'=>'https://www.robertdevore.com']];
+40
View File
@@ -88,6 +88,42 @@ msgstr "Réglages généraux"
msgid "GitHub API Token" msgid "GitHub API Token"
msgstr "Token API GitHub" msgstr "Token API GitHub"
#: admin/admin-settings.php:88
msgid "Gitea Settings"
msgstr ""
#: admin/admin-settings.php:92
msgid "Default Gitea Base URL"
msgstr ""
#: admin/admin-settings.php:96
msgid "Used when a Gitea project has no per-project base URL override."
msgstr ""
#: admin/admin-settings.php:101
msgid "Gitea API Token"
msgstr ""
#: admin/metabox.php:17
msgid "Repository"
msgstr ""
#: admin/metabox.php:47
msgid "Provider"
msgstr ""
#: admin/metabox.php:58
msgid "Gitea Base URL (optional override)"
msgstr ""
#: admin/metabox.php:60
msgid "Leave blank to use the default from Settings."
msgstr ""
#: templates/single-projects.php:73
msgid "View Repo"
msgstr ""
#: admin/admin-settings.php:86 #: admin/admin-settings.php:86
msgid "Templates Settings" msgid "Templates Settings"
msgstr "¨Paramètres des modèles" msgstr "¨Paramètres des modèles"
@@ -189,6 +225,10 @@ msgstr "Aucun registre de modifications disponible."
msgid "Invalid download URL. Please check the GitHub repository." msgid "Invalid download URL. Please check the GitHub repository."
msgstr "URL de téléchargement invalide. Veuillez vérifier le dépôt GitHub." msgstr "URL de téléchargement invalide. Veuillez vérifier le dépôt GitHub."
#: projects-portfolio.php:119
msgid "Invalid download URL. Please check the repository URL."
msgstr "URL de téléchargement invalide. Veuillez vérifier le dépôt."
#: projects-portfolio.php:118 #: projects-portfolio.php:118
msgid "Download Error" msgid "Download Error"
msgstr "Télécharger Erreur" msgstr "Télécharger Erreur"
+40
View File
@@ -189,10 +189,50 @@ msgstr ""
msgid "Invalid download URL. Please check the GitHub repository." msgid "Invalid download URL. Please check the GitHub repository."
msgstr "" msgstr ""
#: projects-portfolio.php:119
msgid "Invalid download URL. Please check the repository URL."
msgstr ""
#: projects-portfolio.php:118 #: projects-portfolio.php:118
msgid "Download Error" msgid "Download Error"
msgstr "" msgstr ""
#: admin/metabox.php:17
msgid "Repository"
msgstr ""
#: admin/metabox.php:47
msgid "Provider"
msgstr ""
#: admin/metabox.php:58
msgid "Gitea Base URL (optional override)"
msgstr ""
#: admin/metabox.php:60
msgid "Leave blank to use the default from Settings."
msgstr ""
#: templates/single-projects.php:73
msgid "View Repo"
msgstr ""
#: admin/admin-settings.php:88
msgid "Gitea Settings"
msgstr ""
#: admin/admin-settings.php:92
msgid "Default Gitea Base URL"
msgstr ""
#: admin/admin-settings.php:96
msgid "Used when a Gitea project has no per-project base URL override."
msgstr ""
#: admin/admin-settings.php:101
msgid "Gitea API Token"
msgstr ""
#: projects-portfolio.php:133 #: projects-portfolio.php:133
msgid "Download Count" msgid "Download Count"
msgstr "" msgstr ""
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
bootstrap="tests/bootstrap.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true">
<testsuites>
<testsuite name="projects-portfolio">
<directory suffix=".php">tests</directory>
</testsuite>
</testsuites>
</phpunit>
File diff suppressed because it is too large Load Diff
+9 -6
View File
@@ -11,7 +11,7 @@
* Plugin Name: Projects Portfolio * Plugin Name: Projects Portfolio
* Description: Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality. * Description: Create a showcase directory for projects (plugins, themes, patterns) with custom post types, taxonomies, and download functionality.
* Plugin URI: https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio * Plugin URI: https://git.keithsolomon.net/Solo-Web-Works/Projects-Portfolio
* Version: 1.0.0 * Version: 1.1.0
* Author: Keith Solomon * Author: Keith Solomon
* Author URI: https://keithsolomon.net * Author URI: https://keithsolomon.net
* License: GPL-2.0+ * License: GPL-2.0+
@@ -39,13 +39,17 @@ $myUpdateChecker = PucFactory::buildUpdateChecker(
$myUpdateChecker->setBranch( 'main' ); $myUpdateChecker->setBranch( 'main' );
// Current plugin version. // Current plugin version.
define( 'PROJECTS_PORTFOLIO_VERSION', time() ); define( 'PROJECTS_PORTFOLIO_VERSION', '1.1.0' );
// Add the required files. // Add the required files.
require 'admin/admin-settings.php'; require 'admin/admin-settings.php';
require 'admin/cpt-taxonomy.php'; require 'admin/cpt-taxonomy.php';
require 'admin/metabox.php'; require 'admin/metabox.php';
require 'includes/helper-functions.php'; require 'includes/helper-functions.php';
require 'includes/providers/interface-repository-provider.php';
require 'includes/providers/class-github-provider.php';
require 'includes/providers/class-gitea-provider.php';
require 'includes/providers/class-provider-factory.php';
/** /**
* Flush rewrite rules on activation. * Flush rewrite rules on activation.
@@ -99,8 +103,7 @@ function projects_portfolio_handle_download_redirect() {
$project_id = get_query_var( 'project_download_id' ); $project_id = get_query_var( 'project_download_id' );
if ( $project_id ) { if ( $project_id ) {
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true ); $download_url = projects_portfolio_get_release_url( (int) $project_id );
$download_url = projects_portfolio_get_github_release_url( $github_url );
if ( $download_url ) { if ( $download_url ) {
// Increment download count. // Increment download count.
@@ -108,12 +111,12 @@ function projects_portfolio_handle_download_redirect() {
++$download_count; ++$download_count;
update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count ); update_post_meta( $project_id, '_projects_portfolio_download_count', $download_count );
// Redirect to the GitHub ZIP file. // Redirect to the release ZIP on the project's host.
wp_safe_redirect( esc_url_raw( $download_url ) ); wp_safe_redirect( esc_url_raw( $download_url ) );
exit; exit;
} else { } else {
wp_die( wp_die(
esc_html__( 'Invalid download URL. Please check the GitHub repository.', 'projects-wp' ), esc_html__( 'Invalid download URL. Please check the repository URL.', 'projects-wp' ),
esc_html__( 'Download Error', 'projects-wp' ), esc_html__( 'Download Error', 'projects-wp' ),
array( 'response' => 404 ) array( 'response' => 404 )
); );
+271
View File
@@ -0,0 +1,271 @@
# Gitea Support for Projects Portfolio
- **Date:** 2026-08-09
- **Plugin:** Projects Portfolio (`projects-wp`)
- **Status:** Approved design, awaiting implementation plan
## 1. Goals
- Per-project support for either GitHub OR Gitea (self-hosted, configurable base URL).
- Feature parity between providers: repo metadata, latest version, release-ZIP download redirect, repo link button.
- Existing GitHub behavior preserved bit-for-bit — no regressions on current installs.
- Provider interface designed so adding a third provider later (e.g. GitLab) is additive and does not require template branching.
- WP PHPUnit tests cover the new adapters, the provider factory, and the metabox/settings surface.
## 2. Non-goals
- GitLab, Bitbucket, or any non-GitHub/non-Gitea provider (interface permits it; implementation deferred).
- Auto-migration on activation (existing posts keep their GitHub association; admin manually switches a project to Gitea when desired).
- Public-Codeberg-only shortcut — Gitea support always treats the base URL as configurable per project and globally.
- New CPT or taxonomy fields beyond what's listed in this spec.
- UI changes outside the project metabox and the plugin settings page.
## 3. Architecture
The plugin introduces a small `Repository_Provider` interface with two adapters and a factory. All call sites consume the interface; only the factory knows which adapter to use.
```
Templates + REST API + Download Redirect
projects_portfolio_get_provider( $post_id ) ← factory
┌────────┴────────┐
▼ ▼
GitHub_Provider Gitea_Provider
│ │
└────────┬────────┘
WP HTTP API + transient cache
```
### 3.1 Files added
- `includes/providers/interface-repository-provider.php``Repository_Provider` interface.
- `includes/providers/class-github-provider.php` — current GitHub logic, repackaged.
- `includes/providers/class-gitea-provider.php` — new adapter.
- `includes/providers/class-provider-factory.php``projects_portfolio_get_provider()`.
- `tests/bootstrap.php`, `tests/test-*.php` — WP PHPUnit suite.
- `composer.json` (dev-only) — pulls in `phpunit/phpunit` and `brain/monkey`.
### 3.2 Files modified
- `includes/helper-functions.php` — adds provider-aware wrappers; legacy GitHub-named wrappers retained verbatim with deprecation PHPDoc.
- `admin/metabox.php` — replaces single GitHub URL field with provider dropdown + repo URL + optional Gitea base URL.
- `admin/admin-settings.php` — adds "Gitea Settings" section (default base URL + token).
- `templates/single-projects.php` — routes data fetches through the provider; no per-provider branching.
- `admin/rest-api.php` — same swap for the REST response builder.
- `projects-portfolio.php``handle_download_redirect()` routes through the provider.
- `README.md` — documents the new metabox fields, Gitea settings, and a "Connecting to a Gitea repo" subsection.
- `languages/projects-wp.pot` (and `.po`/`.l10n.php` siblings) — re-generated for new strings.
## 4. Provider interface
```php
interface Repository_Provider {
public function get_id(): string; // 'github' | 'gitea'
public function get_label(): string; // 'GitHub' | 'Gitea'
public function get_repo_data(): ?array; // normalized shape (see §6)
public function get_release_url(): ?string; // direct asset zip URL or null
public function get_latest_version(): string; // tag_name or 'Unknown'
public function get_repo_browse_url(): string; // for the 'View Repo' button
public function get_owner_data( string $owner_login ): ?array; // avatar/login/html_url
}
```
## 5. Per-project storage
Post meta keys:
| Key | Type | Purpose |
|---|---|---|
| `_projects_portfolio_provider` | `'github'` \| `'gitea'` | Provider selection. Defaults to `github` if missing. |
| `_projects_portfolio_repo_url` | string (URL) | Canonical repo URL. On read, falls back to `_projects_portfolio_github_url` for backward compatibility. |
| `_projects_portfolio_gitea_base_url` | string (URL) | Optional per-project Gitea base URL override. Empty → uses global default. |
| `_projects_portfolio_github_url` | string | **Deprecated, read-only.** Retained so legacy posts don't lose data. |
| `_projects_portfolio_download_count` | int | Unchanged. |
**Lazy migration:** in `projects_portfolio_save_meta_box()`, when a project is saved and `_projects_portfolio_provider` is unset, `_projects_portfolio_repo_url` is empty, but the legacy `_projects_portfolio_github_url` is non-empty, copy the legacy value into `_projects_portfolio_repo_url` and set provider to `github`. No batch update is required.
## 6. Normalized repo data shape
Templates and the REST API read this shape regardless of provider. The GitHub adapter returns it natively; the Gitea adapter maps from Gitea's native response.
```php
[
'owner' => [
'avatar_url' => string,
'login' => string,
'html_url' => string,
],
'updated_at' => string, // ISO 8601
'language' => string,
'license' => [ 'name' => string ],
'stargazers_count' => int,
'forks_count' => int,
'open_issues_count' => int,
]
```
### 6.1 Gitea → normalized mapping
| Gitea field | Normalized field |
|---|---|
| `stars_count` | `stargazers_count` |
| `forks_count` | `forks_count` |
| `open_issues_count` | `open_issues_count` |
| `updated_at` | `updated_at` |
| `language` | `language` |
| `license` (string\|null\|object) | `['name' => string\|'None']` |
| `owner.avatar_url` | `owner.avatar_url` |
| `owner.login` | `owner.login` |
| `owner.html_url` (or fallback `{base}/{owner}`) | `owner.html_url` |
## 7. Endpoints
| Purpose | GitHub | Gitea |
|---|---|---|
| Repo metadata | `https://api.github.com/repos/{owner}/{repo}` | `{base}/api/v1/repos/{owner}/{repo}` |
| Latest release | `…/{owner}/{repo}/releases/latest` | `{base}/api/v1/repos/{owner}/{repo}/releases/latest` |
| Owner data | `https://api.github.com/users/{login}` | `{base}/api/v1/users/{login}` |
| Repo browse | the project URL itself | `{base}/{owner}/{repo}` |
Gitea uses header `Authorization: token <PAT>` against the `projects_portfolio_gitea_api_token` option. GitHub uses the same header scheme against the existing `projects_portfolio_github_api_token` option.
## 8. Release & download logic
**GitHub adapter** (preserves current behavior):
1. Fetch release at `…/releases/latest`.
2. Iterate `assets[]` and return the first entry whose filename ends in `.zip` (field `browser_download_url`).
3. Fallback: return `zipball_url`.
4. `null` if neither is present.
**Gitea adapter**:
1. Fetch the latest release endpoint. Gitea returns an **array** of releases (not a single object), so the adapter takes `releases[0]`.
2. Iterate that release's `assets[]` for the first `.zip` entry.
3. Fallback: build `{base}/{owner}/{repo}/archive/refs/tags/{tag_name}.zip` (Gitea serves this even when no assets are attached).
4. `null` if no tag is present.
5. `get_latest_version()` reads `tag_name` from the same response.
## 9. Global settings
Added to the settings page (between General and Templates sections):
| Option | Default | Purpose |
|---|---|---|
| `projects_portfolio_default_gitea_base_url` | `https://codeberg.org` | Used when a Gitea project has no per-project base URL override. |
| `projects_portfolio_gitea_api_token` | `''` | Bearer token for authenticated Gitea requests. |
Existing `projects_portfolio_github_api_token` is unchanged.
`projects_portfolio_settings()` returns the new keys so templates can read them.
## 10. Metabox
The "GitHub URL" metabox becomes a "Repository" metabox with:
1. **Provider**`<select>` with options `github` (GitHub) and `gitea` (Gitea). Defaults to `github`.
2. **Repository URL**`<input type="url">`. Helper text: "Example: `https://github.com/owner/repo` or `https://codeberg.org/owner/repo`."
3. **Gitea Base URL (optional override)**`<input type="url">`. Visible only when Provider = Gitea. Helper text: "Leave blank to use the default from Settings."
Save behavior writes `_projects_portfolio_provider`, `_projects_portfolio_repo_url`, and `_projects_portfolio_gitea_base_url`. The legacy `_projects_portfolio_github_url` is written **only** during the one-time lazy migration (§5); it is not written on every save.
## 11. Helper-function surface
New provider-aware wrappers (in `includes/helper-functions.php`):
```php
projects_portfolio_get_provider( $post_id ): Repository_Provider
projects_portfolio_get_repo_data( $post_id ): ?array
projects_portfolio_get_release_url( $post_id ): ?string
projects_portfolio_get_version( $post_id ): string
projects_portfolio_get_owner( $post_id ): ?array
projects_portfolio_get_repo_browse_url( $post_id ): string
```
Legacy GitHub-named wrappers are retained **verbatim** with a `_deprecated_function()` PHPDoc note pointing to the new wrappers:
```php
projects_portfolio_get_github_data( $github_url ): ?array
projects_portfolio_get_github_release_url( $github_url ): ?string
projects_portfolio_get_version_from_github( $github_url ): string
projects_portfolio_github_owner( $owner_name ): ?array
```
These wrappers still take a GitHub URL and never route through the provider interface, preserving any external code that called them directly.
## 12. Call-site swaps
1. **`projects-portfolio.php``projects_portfolio_handle_download_redirect()`**
- Read `_projects_portfolio_repo_url` (with legacy fallback) and `_projects_portfolio_provider`.
- Call `projects_portfolio_get_release_url( $post_id )`.
- Error message: "Invalid download URL. Please check the repository URL."
2. **`templates/single-projects.php`**
- `$repo_data = projects_portfolio_get_repo_data( $post_id );`
- `$version = projects_portfolio_get_version( $post_id );`
- `$owner = projects_portfolio_get_owner( $post_id );`
- "View on GitHub" → "View Repo", href = `projects_portfolio_get_repo_browse_url( $post_id )`.
- Field reads (`$repo_data['stargazers_count']` etc.) unchanged because adapters normalize.
3. **`admin/rest-api.php`**
- `projects_portfolio_get_projects_data()` uses `projects_portfolio_get_repo_data()` and `projects_portfolio_get_version()`. Response shape is unchanged.
## 13. Error handling
| Scenario | Behavior |
|---|---|
| Provider meta missing | Default to `github`; fall back to legacy `_projects_portfolio_github_url`. |
| Repo URL invalid/empty | Provider returns `null`; templates omit provider-only rows; download redirect shows wp_die. |
| API 401/403 | Logged, treated as `null`. |
| API 429 | Logged with reset time, treated as `null`. |
| Gitea release array empty | `get_release_url()` returns `null`. |
| Gitea release with no zip asset | Falls back to archive URL pattern. |
| Gitea license = string | Adapter wraps to `['name' => $string]`. |
| Gitea license = null | Adapter returns `['name' => 'None']`. |
| Cache transient corruption | Treated as a miss; re-fetched. |
| Owner fetch failure | Returns `null`; owner avatar block is omitted. |
All errors are logged via `error_log()` with a `Projects Portfolio:` prefix. **No `die()` calls in adapter code** — the legacy wrappers retain their existing `die()` behavior for backward compatibility.
## 14. Testing
WP PHPUnit suite under `tests/`:
- `test-github-provider.php` — correct URL construction, header behavior, asset/zipball fallback, multi-asset selection.
- `test-gitea-provider.php` — correct URL construction, Gitea→normalized mapping, releases-as-array handling, archive-URL fallback, license normalization.
- `test-provider-factory.php` — provider selection from meta; legacy URL fallback; token wiring per provider.
- `test-metabox.php` — save persists new meta keys; legacy key preserved; lazy migration from legacy → new repo URL.
- `test-settings.php` — new options register, save, and sanitize correctly.
Local run: `composer install && vendor/bin/phpunit`.
## 15. Rollout
1. Implement on a feature branch.
2. Manual smoke tests:
- GitHub project on fresh install — output matches current plugin exactly.
- Gitea project on codeberg.org.
- Self-hosted Gitea with custom base URL.
- Project with empty repo URL — download endpoint shows wp_die gracefully.
3. Run the WP PHPUnit suite.
4. Update README per §3.2.
5. Bump `PROJECTS_PORTFOLIO_VERSION` and plugin header Version `1.0.0``1.1.0`.
6. Release as a backward-compatible minor version.
## 16. New translatable strings
- "Repository" (metabox title)
- "Provider"
- "GitHub" (option label)
- "Gitea" (option label)
- "Repository URL"
- "Gitea Base URL (optional override)"
- "Leave blank to use the default from Settings."
- "View Repo"
- "Invalid download URL. Please check the repository URL."
- "Gitea Settings" (settings section heading)
- "Default Gitea Base URL"
- "Gitea API Token"
All strings use the existing `projects-wp` text domain. POT regeneration picks them up.
+27 -76
View File
@@ -12,73 +12,24 @@ if ( $is_fse_theme ) {
$settings = projects_portfolio_settings(); $settings = projects_portfolio_settings();
$project_id = get_the_ID(); $project_id = get_the_ID();
$download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true ); $download_count = (int) get_post_meta( $project_id, '_projects_portfolio_download_count', true );
$github_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
$version = 'Unknown';
$github_data = projects_portfolio_get_github_data( $github_url );
// Extract specific GitHub data. $repo_url = get_post_meta( $project_id, '_projects_portfolio_repo_url', true );
$owner_avatar = $github_data['owner']['avatar_url'] ?? ''; if ( '' === $repo_url ) {
$owner_name = $github_data['owner']['login'] ?? ''; $repo_url = get_post_meta( $project_id, '_projects_portfolio_github_url', true );
$owner_url = $github_data['owner']['html_url'] ?? '';
$last_updated = $github_data['updated_at'] ?? '';
$language = $github_data['language'] ?? '';
$license = $github_data['license']['name'] ?? 'None';
$owner = projects_portfolio_github_owner( $owner_name );
// Fetch the version number from the GitHub API if the URL is set.
if ( $github_url ) {
// Retrieve the GitHub API token from the plugin settings.
$github_token = get_option( 'projects_portfolio_github_api_token', '' );
// Prepare the API URL.
$api_url = str_replace( 'https://github.com/', 'https://api.github.com/repos/', rtrim( $github_url, '/' ) ) . '/releases/latest';
// Check if the response is cached.
$transient_key = 'github_api_response_' . md5( $api_url );
$response = get_transient( $transient_key );
if ( false === $response ) {
// Prepare the request arguments.
$args = [];
if ( ! empty( $github_token ) ) {
$args['headers'] = [
'Authorization' => 'token ' . $github_token,
];
}
// Make the API request.
$response = wp_remote_get( $api_url, $args );
// Cache the response for 1 hour if the request is successful.
if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
set_transient( $transient_key, $response, HOUR_IN_SECONDS );
}
}
// Handle the response.
if ( is_wp_error( $response ) ) {
error_log( 'GitHub API error: ' . $response->get_error_message() );
} else {
$response_code = wp_remote_retrieve_response_code( $response );
$response_body = wp_remote_retrieve_body( $response );
error_log( 'GitHub API response code: ' . $response_code );
error_log( 'GitHub API response body: ' . $response_body );
if ( $response_code === 200 ) {
$data = json_decode( $response_body, true );
// Check if tag_name exists in the response.
if ( isset( $data['tag_name'] ) ) {
$version = esc_html( $data['tag_name'] );
}
} else {
error_log( "GitHub API error: HTTP $response_code for URL $api_url" );
}
}
} }
// Format last updated date. $repo_data = projects_portfolio_get_repo_data( $project_id );
$version = projects_portfolio_get_version( $project_id );
$owner = projects_portfolio_get_owner( $project_id );
$owner_avatar = $repo_data['owner']['avatar_url'] ?? '';
$owner_name = $repo_data['owner']['login'] ?? '';
$owner_url = $repo_data['owner']['html_url'] ?? '';
$last_updated = $repo_data['updated_at'] ?? '';
$language = $repo_data['language'] ?? '';
$license = $repo_data['license']['name'] ?? 'None';
$browse_url = $repo_url ? projects_portfolio_get_repo_browse_url( $project_id ) : '';
if ( $last_updated ) { if ( $last_updated ) {
$last_updated = date_i18n( get_option( 'date_format' ), strtotime( $last_updated ) ); $last_updated = date_i18n( get_option( 'date_format' ), strtotime( $last_updated ) );
} }
@@ -117,12 +68,12 @@ if ( $last_updated ) {
<a href="<?php echo esc_url( site_url( '/download/' . $project_id ) ); ?>" class="button project-download-button"> <a href="<?php echo esc_url( site_url( '/download/' . $project_id ) ); ?>" class="button project-download-button">
<?php esc_html_e( 'Download Now', 'projects-wp' ); ?> <?php esc_html_e( 'Download Now', 'projects-wp' ); ?>
</a> </a>
<?php if ( $github_url ) : ?> <?php if ( $repo_url ) : ?>
<a href="<?php echo esc_url( $github_url ); ?>" class="button project-github-button" target="_blank" rel="noopener noreferrer"> <a href="<?php echo esc_url( $browse_url ); ?>" class="button project-github-button" target="_blank" rel="noopener noreferrer">
<?php esc_html_e( 'View on GitHub', 'projects-wp' ); ?> <?php esc_html_e( 'View Repo', 'projects-wp' ); ?>
</a> </a>
<?php else : ?> <?php else : ?>
<p><?php esc_html_e( 'GitHub Repository:', 'projects-wp' ); ?> <?php esc_html_e( 'No repository linked.', 'projects-wp' ); ?></p> <p><?php esc_html_e( 'Repository:', 'projects-wp' ); ?> <?php esc_html_e( 'No repository linked.', 'projects-wp' ); ?></p>
<?php endif; ?> <?php endif; ?>
</div> </div>
@@ -163,23 +114,23 @@ if ( $last_updated ) {
</tr> </tr>
<?php endif; ?> <?php endif; ?>
<?php <?php
if ( ! empty( $github_data ) ) : if ( ! empty( $repo_data ) ) :
if ( isset( $github_data['stargazers_count'] ) && $settings['templates']['stargazers_count'] ) : ?> if ( isset( $repo_data['stargazers_count'] ) && $settings['templates']['stargazers_count'] ) : ?>
<tr> <tr>
<th><?php esc_html_e( 'Stars:', 'projects-wp' ); ?></th> <th><?php esc_html_e( 'Stars:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $github_data['stargazers_count'] ); ?></td> <td><?php esc_html_e( $repo_data['stargazers_count'] ); ?></td>
</tr> </tr>
<?php endif; <?php endif;
if ( isset( $github_data['forks_count'] ) && $settings['templates']['forks'] ) : ?> if ( isset( $repo_data['forks_count'] ) && $settings['templates']['forks'] ) : ?>
<tr> <tr>
<th><?php esc_html_e( 'Forks:', 'projects-wp' ); ?></th> <th><?php esc_html_e( 'Forks:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $github_data['forks_count'] ); ?></td> <td><?php esc_html_e( $repo_data['forks_count'] ); ?></td>
</tr> </tr>
<?php endif; <?php endif;
if ( isset( $github_data['open_issues_count'] ) && $settings['templates']['open_issues_count'] ) : ?> if ( isset( $repo_data['open_issues_count'] ) && $settings['templates']['open_issues_count'] ) : ?>
<tr> <tr>
<th><?php esc_html_e( 'Issues:', 'projects-wp' ); ?></th> <th><?php esc_html_e( 'Issues:', 'projects-wp' ); ?></th>
<td><?php esc_html_e( $github_data['open_issues_count'] ); ?></td> <td><?php esc_html_e( $repo_data['open_issues_count'] ); ?></td>
</tr> </tr>
<?php endif; <?php endif;
endif; endif;
@@ -189,7 +140,7 @@ if ( $last_updated ) {
</div> </div>
<?php if ( $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?> <?php if ( $settings['templates']['github_owner'] && $owner_avatar && $owner_name ) : ?>
<a href="<?php esc_html_e( $github_url ); ?>"> <a href="<?php echo esc_url( $browse_url ); ?>">
<div class="project-owner"> <div class="project-owner">
<span> <span>
<img src="<?php echo esc_url( $owner_avatar ); ?>" alt="<?php esc_attr_e( $owner_name ); ?>" class="owner-avatar" style="width: 50px; height: 50px; border-radius: 50%;" /> <img src="<?php echo esc_url( $owner_avatar ); ?>" alt="<?php esc_attr_e( $owner_name ); ?>" class="owner-avatar" style="width: 50px; height: 50px; border-radius: 50%;" />
+21
View File
@@ -0,0 +1,21 @@
<?php
// Load Composer dev autoload (PHPUnit + Brain\Monkey + polyfills).
$autoload = dirname( __DIR__ ) . '/vendor/autoload.php';
if ( ! file_exists( $autoload ) ) {
fwrite( STDERR, "Run `composer install` before running tests.\n" );
exit( 1 );
}
require_once $autoload;
// Brain\Monkey sets up minimal function stubs (apply_filters, plugin_dir_path, etc.).
// Must run BEFORE the WP function stubs in wp-stubs.php are loaded, because Patchwork
// (used by Brain\Monkey) cannot redefine functions defined in the same file/import
// chain that loaded Patchwork itself. Loading the stubs in a separate file imported
// after Brain\Monkey\setUp() lets Patchwork track them properly.
\Brain\Monkey\setUp();
require_once __DIR__ . '/wp-stubs.php';
register_shutdown_function( function () {
\Brain\Monkey\tearDown();
} );
+177
View File
@@ -0,0 +1,177 @@
<?php
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
require_once __DIR__ . '/../includes/providers/class-gitea-provider.php';
/**
* @covers \Gitea_Provider
*/
class Gitea_Provider_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
private function make( string $base = 'https://codeberg.org', string $token = '' ): Gitea_Provider {
return new Gitea_Provider( 'https://codeberg.org/owner/repo', $base, $token );
}
public function test_get_id_and_label(): void {
$p = $this->make();
$this->assertSame( 'gitea', $p->get_id() );
$this->assertSame( 'Gitea', $p->get_label() );
}
public function test_repo_data_normalizes_gitea_fields(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->with( 'https://codeberg.org/api/v1/repos/owner/repo', \Brain\Monkey\Actions\anyArgs() )
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [
'avatar_url' => 'https://codeberg.org/avatars/owner',
'login' => 'owner',
'html_url' => 'https://codeberg.org/owner',
],
'updated_at' => '2026-02-03T04:05:06Z',
'language' => 'PHP',
'license' => [ 'name' => 'MIT' ],
'stars_count' => 100,
'forks_count' => 10,
'open_issues_count' => 5,
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 100, $data['stargazers_count'] );
$this->assertSame( 10, $data['forks_count'] );
$this->assertSame( 5, $data['open_issues_count'] );
$this->assertSame( 'MIT', $data['license']['name'] );
$this->assertSame( 'owner', $data['owner']['login'] );
}
public function test_repo_data_handles_string_license(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [],
'stars_count' => 0,
'forks_count' => 0,
'open_issues_count' => 0,
'license' => 'Apache-2.0',
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 'Apache-2.0', $data['license']['name'] );
}
public function test_repo_data_handles_null_license(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [],
'stars_count' => 0,
'forks_count' => 0,
'open_issues_count' => 0,
'license' => null,
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 'None', $data['license']['name'] );
}
public function test_release_url_picks_zip_asset_from_first_release(): void {
$body = json_encode( [
[
'tag_name' => 'v2.0.0',
'assets' => [
[ 'name' => 'source.tar.gz', 'browser_download_url' => 'https://example/x.tar.gz' ],
[ 'name' => 'release.zip', 'browser_download_url' => 'https://example/release.zip' ],
],
],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->twice()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
$this->assertSame( 'https://example/release.zip', $this->make()->get_release_url() );
$this->assertSame( 'v2.0.0', $this->make()->get_latest_version() );
}
public function test_release_url_falls_back_to_archive_url(): void {
$body = json_encode( [
[ 'tag_name' => 'v0.1.0', 'assets' => [] ],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $body,
] );
$this->assertSame(
'https://codeberg.org/owner/repo/archive/refs/tags/v0.1.0.zip',
$this->make()->get_release_url()
);
}
public function test_release_url_empty_array_returns_null(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => '[]',
] );
$this->assertNull( $this->make()->get_release_url() );
}
public function test_repo_browse_url_is_built_from_base(): void {
$this->assertSame(
'https://codeberg.org/owner/repo',
$this->make()->get_repo_browse_url()
);
}
public function test_http_failure_returns_null(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( new \WP_Error( 'http_error', 'boom' ) );
$this->assertNull( $this->make()->get_repo_data() );
}
public function test_self_hosted_base_url_is_used(): void {
$provider = new Gitea_Provider(
'https://git.example.org/owner/repo',
'https://git.example.org',
''
);
$this->assertSame(
'https://git.example.org/owner/repo',
$provider->get_repo_browse_url()
);
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
require_once __DIR__ . '/../includes/providers/class-github-provider.php';
/**
* @covers \GitHub_Provider
*/
class GitHub_Provider_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
private function make( string $token = '' ): GitHub_Provider {
return new GitHub_Provider( 'https://github.com/owner/repo', $token );
}
public function test_get_id_and_label(): void {
$p = $this->make();
$this->assertSame( 'github', $p->get_id() );
$this->assertSame( 'GitHub', $p->get_label() );
}
public function test_get_repo_data_normalizes_response(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->with( 'https://api.github.com/repos/owner/repo', \Brain\Monkey\Actions\anyArgs() )
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => json_encode( [
'owner' => [
'avatar_url' => 'https://avatars.example/owner.png',
'login' => 'owner',
'html_url' => 'https://github.com/owner',
],
'updated_at' => '2026-01-02T03:04:05Z',
'language' => 'PHP',
'license' => [ 'name' => 'MIT' ],
'stargazers_count' => 42,
'forks_count' => 7,
'open_issues_count' => 3,
] ),
] );
$data = $this->make()->get_repo_data();
$this->assertSame( 'owner', $data['owner']['login'] );
$this->assertSame( 42, $data['stargazers_count'] );
$this->assertSame( 'MIT', $data['license']['name'] );
}
public function test_release_url_prefers_zip_asset(): void {
$release_body = json_encode( [
'tag_name' => 'v1.2.3',
'assets' => [
[ 'name' => 'project.tar.gz', 'browser_download_url' => 'https://example/x.tar.gz' ],
[ 'name' => 'project.zip', 'browser_download_url' => 'https://example/x.zip' ],
],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->twice()
->with( 'https://api.github.com/repos/owner/repo/releases/latest', \Brain\Monkey\Actions\anyArgs() )
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $release_body,
] );
$this->assertSame( 'https://example/x.zip', $this->make()->get_release_url() );
$this->assertSame( 'v1.2.3', $this->make()->get_latest_version() );
}
public function test_release_url_falls_back_to_zipball(): void {
$release_body = json_encode( [
'tag_name' => 'v0.0.1',
'zipball_url' => 'https://api.github.com/repos/owner/repo/zipball/v0.0.1',
'assets' => [],
] );
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( [
'response' => [ 'code' => 200 ],
'headers' => [],
'body' => $release_body,
] );
$this->assertSame(
'https://api.github.com/repos/owner/repo/zipball/v0.0.1',
$this->make()->get_release_url()
);
}
public function test_repo_browse_url_returns_input(): void {
$this->assertSame(
'https://github.com/owner/repo',
$this->make()->get_repo_browse_url()
);
}
public function test_http_failure_returns_null(): void {
\Brain\Monkey\Functions\expect( 'wp_remote_get' )
->once()
->andReturn( new \WP_Error( 'http_error', 'boom' ) );
$this->assertNull( $this->make()->get_repo_data() );
}
}
+145
View File
@@ -0,0 +1,145 @@
<?php
require_once __DIR__ . '/../admin/metabox.php';
/**
* @covers \projects_portfolio_save_meta_box
*/
class Metabox_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
$_POST = [];
\Brain\Monkey\tearDown();
}
/** @var array<string,string> */
private array $saved = [];
protected function setUpSaveStubs(): void {
$this->saved = [];
\Brain\Monkey\Functions\stubs( [
'update_post_meta' => function ( $post_id, $key, $value ) {
$this->saved[ $key ] = $value;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
}
public function test_save_persists_provider_and_repo_url(): void {
$this->setUpSaveStubs();
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
$_POST['projects_portfolio_provider'] = 'gitea';
$_POST['projects_portfolio_repo_url'] = 'https://codeberg.org/owner/repo';
$_POST['projects_portfolio_gitea_base_url'] = 'https://codeberg.org';
projects_portfolio_save_meta_box( 42 );
$this->assertSame( 'gitea', $this->saved['_projects_portfolio_provider'] );
$this->assertSame( 'https://codeberg.org/owner/repo', $this->saved['_projects_portfolio_repo_url'] );
$this->assertSame( 'https://codeberg.org', $this->saved['_projects_portfolio_gitea_base_url'] );
}
public function test_lazy_migration_from_legacy_url(): void {
$existing = [
'_projects_portfolio_provider' => '',
'_projects_portfolio_repo_url' => '',
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
];
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
return $existing[ $key ] ?? '';
},
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
$existing[ $key ] = $value;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
// No $_POST provider/repo_url. Lazy migration should fire.
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
projects_portfolio_save_meta_box( 1 );
$this->assertSame( 'github', $existing['_projects_portfolio_provider'] );
$this->assertSame( 'https://github.com/owner/repo', $existing['_projects_portfolio_repo_url'] );
}
public function test_switching_to_gitea_deletes_legacy_github_url(): void {
$existing = [
'_projects_portfolio_provider' => 'github',
'_projects_portfolio_repo_url' => 'https://github.com/owner/repo',
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
];
$deleted = [];
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
return $existing[ $key ] ?? '';
},
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
$existing[ $key ] = $value;
return true;
},
'delete_post_meta' => function ( $post_id, $key ) use ( &$existing, &$deleted ) {
unset( $existing[ $key ] );
$deleted[] = $key;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
$_POST['projects_portfolio_provider'] = 'gitea';
$_POST['projects_portfolio_repo_url'] = 'https://codeberg.org/owner/repo';
projects_portfolio_save_meta_box( 7 );
$this->assertSame( 'gitea', $existing['_projects_portfolio_provider'] );
$this->assertSame( 'https://codeberg.org/owner/repo', $existing['_projects_portfolio_repo_url'] );
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
$this->assertContains( '_projects_portfolio_github_url', $deleted );
}
public function test_github_with_empty_repo_url_deletes_legacy_github_url(): void {
$existing = [
'_projects_portfolio_provider' => 'github',
'_projects_portfolio_repo_url' => 'https://github.com/owner/repo',
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
];
$deleted = [];
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( &$existing ) {
return $existing[ $key ] ?? '';
},
'update_post_meta' => function ( $post_id, $key, $value ) use ( &$existing ) {
$existing[ $key ] = $value;
return true;
},
'delete_post_meta' => function ( $post_id, $key ) use ( &$existing, &$deleted ) {
unset( $existing[ $key ] );
$deleted[] = $key;
return true;
},
'wp_verify_nonce' => function () { return true; },
'wp_nonce_field' => function () { /* noop */ },
] );
$_POST['projects_portfolio_meta_box_nonce'] = 'nonce';
$_POST['projects_portfolio_provider'] = 'github';
$_POST['projects_portfolio_repo_url'] = '';
projects_portfolio_save_meta_box( 9 );
$this->assertSame( 'github', $existing['_projects_portfolio_provider'] );
$this->assertSame( '', $existing['_projects_portfolio_repo_url'] );
$this->assertArrayNotHasKey( '_projects_portfolio_github_url', $existing );
$this->assertContains( '_projects_portfolio_github_url', $deleted );
}
}
+66
View File
@@ -0,0 +1,66 @@
<?php
require_once __DIR__ . '/../includes/providers/interface-repository-provider.php';
require_once __DIR__ . '/../includes/providers/class-github-provider.php';
require_once __DIR__ . '/../includes/providers/class-gitea-provider.php';
require_once __DIR__ . '/../includes/providers/class-provider-factory.php';
/**
* @covers \projects_portfolio_get_provider
*/
class Provider_Factory_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
private function stub_meta( array $provider_meta, array $options = [] ): void {
\Brain\Monkey\Functions\stubs( [
'get_post_meta' => function ( $post_id, $key, $single = false ) use ( $provider_meta ) {
return $provider_meta[ $key ] ?? '';
},
'get_option' => function ( $key, $default = '' ) use ( $options ) {
return $options[ $key ] ?? $default;
},
] );
}
public function test_defaults_to_github_when_provider_meta_missing(): void {
$this->stub_meta( [
'_projects_portfolio_github_url' => 'https://github.com/owner/repo',
] );
$provider = projects_portfolio_get_provider( 42 );
$this->assertSame( 'github', $provider->get_id() );
$this->assertSame( 'https://github.com/owner/repo', $provider->get_repo_browse_url() );
}
public function test_uses_repo_url_when_provider_explicit(): void {
$this->stub_meta( [
'_projects_portfolio_provider' => 'gitea',
'_projects_portfolio_repo_url' => 'https://codeberg.org/owner/repo',
], [
'projects_portfolio_default_gitea_base_url' => 'https://codeberg.org',
'projects_portfolio_gitea_api_token' => 'gitea-token',
] );
$provider = projects_portfolio_get_provider( 7 );
$this->assertSame( 'gitea', $provider->get_id() );
}
public function test_per_project_gitea_base_overrides_global(): void {
$this->stub_meta( [
'_projects_portfolio_provider' => 'gitea',
'_projects_portfolio_repo_url' => 'https://git.example.org/owner/repo',
'_projects_portfolio_gitea_base_url' => 'https://git.example.org',
], [
'projects_portfolio_default_gitea_base_url' => 'https://codeberg.org',
] );
$provider = projects_portfolio_get_provider( 7 );
$this->assertSame( 'https://git.example.org/owner/repo', $provider->get_repo_browse_url() );
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
require_once __DIR__ . '/../includes/helper-functions.php';
/**
* @covers \projects_portfolio_settings
*/
class Settings_Test extends \PHPUnit\Framework\TestCase {
protected function setUp(): void {
\Brain\Monkey\setUp();
}
protected function tearDown(): void {
\Brain\Monkey\tearDown();
}
public function test_settings_aggregator_includes_gitea_keys(): void {
\Brain\Monkey\Functions\stubs( [
'get_option' => function ( $key, $default = '' ) {
$values = [
'projects_portfolio_default_gitea_base_url' => 'https://git.example.org',
'projects_portfolio_gitea_api_token' => 'secret',
];
return $values[ $key ] ?? $default;
},
] );
$settings = projects_portfolio_settings();
$this->assertSame( 'https://git.example.org', $settings['default_gitea_base_url'] );
$this->assertSame( 'secret', $settings['gitea_api_token'] );
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace Brain\Monkey\Actions {
if ( ! function_exists( __NAMESPACE__ . '\\anyArgs' ) && function_exists( 'anyArgs' ) ) {
function anyArgs( ...$args ) { return \anyArgs( ...$args ); }
}
}
namespace {
// Shim: ensure Mockery's anyArgs() helper is loaded.
if ( ! function_exists( 'anyArgs' ) ) {
require_once dirname( __DIR__ ) . '/vendor/mockery/mockery/library/helpers.php';
}
// Define WP constants the helpers rely on.
if ( ! defined( 'HOUR_IN_SECONDS' ) ) {
define( 'HOUR_IN_SECONDS', 3600 );
}
if ( ! defined( 'WPINC' ) ) {
define( 'WPINC', 'wp-includes' );
}
if ( ! defined( 'ABSPATH' ) ) {
define( 'ABSPATH', __DIR__ . '/' );
}
// Provide minimal WordPress function stubs used by the providers under test.
if ( ! function_exists( 'get_option' ) ) {
function get_option( $key, $default = false ) { return $default; }
}
if ( ! function_exists( 'get_transient' ) ) {
function get_transient( $key ) { return false; }
}
if ( ! function_exists( 'set_transient' ) ) {
function set_transient( $key, $value, $expiration ) { return true; }
}
if ( ! function_exists( 'delete_transient' ) ) {
function delete_transient( $key ) { return true; }
}
if ( ! function_exists( 'wp_remote_get' ) ) {
function wp_remote_get( $url, $args = [] ) { return new \WP_Error( 'no_http', 'no http' ); }
}
if ( ! function_exists( 'is_wp_error' ) ) {
function is_wp_error( $thing ) { return $thing instanceof \WP_Error; }
}
if ( ! function_exists( 'wp_remote_retrieve_response_code' ) ) {
function wp_remote_retrieve_response_code( $response ) {
if ( $response instanceof \WP_Error ) { return 0; }
return $response['response']['code'] ?? 0;
}
}
if ( ! function_exists( 'wp_remote_retrieve_body' ) ) {
function wp_remote_retrieve_body( $response ) {
if ( $response instanceof \WP_Error ) { return ''; }
return $response['body'] ?? '';
}
}
if ( ! function_exists( 'wp_remote_retrieve_header' ) ) {
function wp_remote_retrieve_header( $response, $header ) {
if ( $response instanceof \WP_Error ) { return ''; }
$headers = $response['headers'] ?? [];
$key = strtolower( $header );
return $headers[ $key ] ?? '';
}
}
if ( ! function_exists( 'error_log' ) ) {
function error_log( $message ) { /* swallow during tests */ }
}
if ( ! function_exists( 'get_post_meta' ) ) {
function get_post_meta( $post_id, $key = '', $single = false ) { return ''; }
}
if ( ! function_exists( 'update_post_meta' ) ) {
function update_post_meta( $post_id, $key, $value ) { return true; }
}
if ( ! function_exists( 'delete_post_meta' ) ) {
function delete_post_meta( $post_id, $key ) { return true; }
}
if ( ! function_exists( 'wp_unslash' ) ) {
function wp_unslash( $value ) { return $value; }
}
if ( ! function_exists( 'esc_url_raw' ) ) {
function esc_url_raw( $url ) { return $url; }
}
// Minimal WP_Error stub if Brain\Monkey doesn't supply one.
if ( ! class_exists( 'WP_Error' ) ) {
class WP_Error {
private $code;
private $message;
public function __construct( $code = '', $message = '' ) {
$this->code = $code;
$this->message = $message;
}
public function get_error_code() { return $this->code; }
public function get_error_message() { return $this->message; }
}
}
}